diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..29fbc876e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure shell scripts always have LF line endings on all platforms +*.sh text eol=lf +.husky/* text eol=lf diff --git a/.github/SETUP_DOTNET_VERSIONING.md b/.github/SETUP_DOTNET_VERSIONING.md index 4f61ec31e..6b17b08dd 100644 --- a/.github/SETUP_DOTNET_VERSIONING.md +++ b/.github/SETUP_DOTNET_VERSIONING.md @@ -163,7 +163,7 @@ dotnet add package ConduitLLM.Configuration ### Dev Versions ```bash # Add GitHub Packages source -dotnet nuget add source https://nuget.pkg.github.com/knnlabs/index.json -n github +dotnet nuget add source https://nuget.pkg.github.com/nickna/index.json -n github # Install dev versions dotnet add package ConduitLLM.Core --version 0.1.0-dev.20250619123456 --source github diff --git a/.github/workflows/README.md b/.github/workflows/README.md index acec29409..dea4e70e3 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -13,7 +13,7 @@ This repository uses a simplified, industry-standard CI/CD pipeline. - Publishes NPM packages with `next` tag (only from `master`) **Artifacts produced from `master`:** -- Docker: `ghcr.io/knnlabs/conduit-{webadmin,http,admin}:latest` +- Docker: `ghcr.io/nickna/conduit-{webadmin,http,admin}:latest` - NPM: `@conduitllm/{admin,core}@next` ### 2. Release (`release.yml`) @@ -25,7 +25,7 @@ This repository uses a simplified, industry-standard CI/CD pipeline. - Publishes versioned NPM packages **Artifacts produced:** -- Docker: `ghcr.io/knnlabs/conduit-{webadmin,http,admin}:1.2.3` +- Docker: `ghcr.io/nickna/conduit-{webadmin,http,admin}:1.2.3` - NPM: `@conduitllm/{admin,core}@1.2.3` - GitHub Release with changelog @@ -52,9 +52,9 @@ This repository uses a simplified, industry-standard CI/CD pipeline. ## Artifact Locations -- **Docker Images:** https://github.com/orgs/knnlabs/packages +- **Docker Images:** https://github.com/users/nickna/packages - **NPM Packages:** https://www.npmjs.com/~knn_labs -- **Security Results:** https://github.com/knnlabs/Conduit/security/code-scanning +- **Security Results:** https://github.com/nickna/Conduit/security/code-scanning ## Design Principles diff --git a/.github/workflows/archive-2024-08/build-and-release.yml b/.github/workflows/archive-2024-08/build-and-release.yml index 886dcca62..4a2feb97a 100644 --- a/.github/workflows/archive-2024-08/build-and-release.yml +++ b/.github/workflows/archive-2024-08/build-and-release.yml @@ -278,13 +278,13 @@ jobs: matrix: include: - service: webadmin - image: ghcr.io/knnlabs/conduit-webadmin + image: ghcr.io/nickna/conduit-webadmin dockerfile: ./ConduitLLM.WebAdmin/Dockerfile - service: http - image: ghcr.io/knnlabs/conduit-http + image: ghcr.io/nickna/conduit-http dockerfile: ./ConduitLLM.Http/Dockerfile - service: admin - image: ghcr.io/knnlabs/conduit-admin + image: ghcr.io/nickna/conduit-admin dockerfile: ./ConduitLLM.Admin/Dockerfile steps: @@ -364,11 +364,11 @@ jobs: matrix: include: - service: webadmin - image: ghcr.io/knnlabs/conduit-webadmin + image: ghcr.io/nickna/conduit-webadmin - service: http - image: ghcr.io/knnlabs/conduit-http + image: ghcr.io/nickna/conduit-http - service: admin - image: ghcr.io/knnlabs/conduit-admin + image: ghcr.io/nickna/conduit-admin steps: - name: Set up Docker Buildx @@ -450,6 +450,6 @@ jobs: echo "Docker images have been successfully built:" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Images are available at:" >> $GITHUB_STEP_SUMMARY - echo "- ๐ŸŒ `ghcr.io/knnlabs/conduit-webadmin` (linux/amd64)" >> $GITHUB_STEP_SUMMARY - echo "- ๐Ÿ”Œ `ghcr.io/knnlabs/conduit-http` (linux/amd64)" >> $GITHUB_STEP_SUMMARY - echo "- ๐Ÿ› ๏ธ `ghcr.io/knnlabs/conduit-admin` (linux/amd64)" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "- ๐ŸŒ `ghcr.io/nickna/conduit-webadmin` (linux/amd64)" >> $GITHUB_STEP_SUMMARY + echo "- ๐Ÿ”Œ `ghcr.io/nickna/conduit-http` (linux/amd64)" >> $GITHUB_STEP_SUMMARY + echo "- ๐Ÿ› ๏ธ `ghcr.io/nickna/conduit-admin` (linux/amd64)" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/archive-2024-08/release-orchestration.yml b/.github/workflows/archive-2024-08/release-orchestration.yml index cd182aacf..87c76ada7 100644 --- a/.github/workflows/archive-2024-08/release-orchestration.yml +++ b/.github/workflows/archive-2024-08/release-orchestration.yml @@ -142,9 +142,9 @@ jobs: ### Docker \`\`\`bash - docker pull ghcr.io/knnlabs/conduit-webadmin:v$VERSION - docker pull ghcr.io/knnlabs/conduit-http:v$VERSION - docker pull ghcr.io/knnlabs/conduit-admin:v$VERSION + docker pull ghcr.io/nickna/conduit-webadmin:v$VERSION + docker pull ghcr.io/nickna/conduit-http:v$VERSION + docker pull ghcr.io/nickna/conduit-admin:v$VERSION \`\`\` ### NPM SDKs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dff2af76f..5b45d7246 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ env: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: true jobs: # Quick validation that everything builds @@ -59,7 +59,7 @@ jobs: cache-dependency-path: | SDKs/Node/package-lock.json SDKs/Node/Admin/package-lock.json - SDKs/Node/Core/package-lock.json + SDKs/Node/Gateway/package-lock.json SDKs/Node/Common/package-lock.json WebAdmin/package-lock.json @@ -117,11 +117,12 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/knnlabs/conduit-${{ matrix.service }} + images: ${{ env.REGISTRY }}/nickna/conduit-${{ matrix.service }} tags: | type=ref,event=branch type=ref,event=pr - type=sha,prefix={{branch}}- + type=sha,prefix={{branch}}-,enable=${{ github.event_name != 'pull_request' }} + type=sha,prefix=pr-${{ github.event.pull_request.number }}-,enable=${{ github.event_name == 'pull_request' }} type=raw,value=latest,enable={{is_default_branch}} - name: Set up Docker Buildx @@ -165,9 +166,9 @@ jobs: cache-dependency-path: | SDKs/Node/package-lock.json SDKs/Node/Admin/package-lock.json - SDKs/Node/Core/package-lock.json + SDKs/Node/Gateway/package-lock.json SDKs/Node/Common/package-lock.json - + - name: Build and Publish run: | cd SDKs/Node @@ -189,8 +190,8 @@ jobs: npm version "${CURRENT_VERSION}-next.${TIMESTAMP}" --no-git-tag-version npm publish --tag next --access public - # Update version and publish Core - cd ../Core + # Update version and publish Gateway + cd ../Gateway CURRENT_VERSION=$(node -p "require('./package.json').version") npm version "${CURRENT_VERSION}-next.${TIMESTAMP}" --no-git-tag-version npm publish --tag next --access public diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8ffff2504..8b07e96b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -129,7 +129,7 @@ jobs: cache-dependency-path: | SDKs/Node/package-lock.json SDKs/Node/Admin/package-lock.json - SDKs/Node/Core/package-lock.json + SDKs/Node/Gateway/package-lock.json SDKs/Node/Common/package-lock.json WebAdmin/package-lock.json diff --git a/.github/workflows/deprecated/docker-release.yml b/.github/workflows/deprecated/docker-release.yml index 20c9747a3..34b718f9c 100644 --- a/.github/workflows/deprecated/docker-release.yml +++ b/.github/workflows/deprecated/docker-release.yml @@ -45,7 +45,7 @@ jobs: id: meta-webadmin uses: docker/metadata-action@v5 with: - images: ghcr.io/knnlabs/conduit-webadmin + images: ghcr.io/nickna/conduit-webadmin tags: | type=ref,event=branch type=ref,event=pr @@ -73,7 +73,7 @@ jobs: id: meta-http uses: docker/metadata-action@v5 with: - images: ghcr.io/knnlabs/conduit-http + images: ghcr.io/nickna/conduit-http tags: | type=ref,event=branch type=ref,event=pr diff --git a/.github/workflows/deprecated/publish-docker.yml b/.github/workflows/deprecated/publish-docker.yml index 2404e8dd6..3a3b10070 100644 --- a/.github/workflows/deprecated/publish-docker.yml +++ b/.github/workflows/deprecated/publish-docker.yml @@ -28,7 +28,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository }} # Uses owner/repo format like knnlabs/Conduit + images: ghcr.io/${{ github.repository }} # Uses owner/repo format like nickna/Conduit tags: | type=sha # Tag with the git commit SHA type=raw,value=latest,enable=${{ github.ref_name == 'master' }} # 'latest' only for master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea8e61bee..3978f4d85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,8 +78,8 @@ jobs: platforms: linux/amd64 push: true tags: | - ${{ env.REGISTRY }}/knnlabs/conduit-${{ matrix.service }}:${{ needs.release.outputs.version }} - ${{ env.REGISTRY }}/knnlabs/conduit-${{ matrix.service }}:latest + ${{ env.REGISTRY }}/nickna/conduit-${{ matrix.service }}:${{ needs.release.outputs.version }} + ${{ env.REGISTRY }}/nickna/conduit-${{ matrix.service }}:latest cache-from: type=gha,scope=${{ matrix.service }} cache-to: type=gha,scope=${{ matrix.service }},mode=max @@ -100,7 +100,7 @@ jobs: cache-dependency-path: | SDKs/Node/package-lock.json SDKs/Node/Admin/package-lock.json - SDKs/Node/Core/package-lock.json + SDKs/Node/Gateway/package-lock.json SDKs/Node/Common/package-lock.json - name: Update versions and publish diff --git a/.gitignore b/.gitignore index 34b63db00..50be8f40f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,9 @@ artifacts/ # ASP.NET Scaffolding ScaffoldingReadMe.txt +# Visual Studio launch settings (developer-specific) +**/Properties/launchSettings.json + # StyleCop StyleCopReport.xml @@ -383,6 +386,9 @@ FodyWeavers.xsd *.sln.iml .idea/ +# Claude Code local settings +.claude/ + # macOS .DS_Store @@ -448,3 +454,4 @@ replicate-text-models.sql replicate-video-models.sql scripts/db/replicate/replicate-text-models.sql scripts/db/replicate/replicate-video-models.sql +scripts/db/providers/openrouter-models.sql diff --git a/.husky/pre-push b/.husky/pre-push index d1dbf8cd5..0fea8472b 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,4 @@ +#!/bin/sh # Pre-push hook to prevent pushing code with lint errors echo "๐Ÿ” Running pre-push lint checks..." @@ -9,7 +10,15 @@ if [ "$CI" = "true" ]; then fi # Run the STRICT validation script (same as CI/CD) -./scripts/test/validate-eslint-strict.sh +# Check which PowerShell command is available +if command -v pwsh &> /dev/null; then + pwsh -NoProfile -Command "& ./scripts/test/validate-eslint-strict.ps1" +elif command -v powershell.exe &> /dev/null; then + powershell.exe -NoProfile -Command "& ./scripts/test/validate-eslint-strict.ps1" +else + echo "โš ๏ธ PowerShell not found, skipping lint check" + exit 0 +fi if [ $? -ne 0 ]; then echo "" diff --git a/AGENTS.md b/AGENTS.md index a07a315ec..a25ae5147 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -380,7 +380,7 @@ for await (const event of stream) { # docker-compose.yml for high-throughput agents services: conduit-http: - image: ghcr.io/knnlabs/conduit-http:latest + image: ghcr.io/nickna/conduit-http:latest environment: CONDUIT_FUNCTION_EXECUTION_CONCURRENCY: 50 CONDUIT_FUNCTION_TIMEOUT_SECONDS: 30 diff --git a/CLAUDE.md b/CLAUDE.md index 0fafd7518..12a6f362e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **These commands break the development container and force a 5+ minute restart:** - `npm run build` (anywhere in WebAdmin directory) - `cd WebAdmin && npm run build` -- `./scripts/dev/dev-workflow.sh build-webadmin` (production testing only) +- `./scripts/dev/dev-workflow.ps1 build-webadmin` (production testing only) **Why?** The development container uses an isolated `.next` directory. Running npm build on the host corrupts the container's build state. @@ -33,7 +33,7 @@ Use these instead: - Hot reloading automatically validates code changes ### โŒ FORBIDDEN DEVELOPMENT COMMANDS -- `docker compose up` for development (always use `./scripts/dev/start-dev.sh`) +- `docker compose up` for development (always use `./scripts/dev/start-dev.ps1`) **If you run forbidden commands, you will:** 1. Break the development environment @@ -47,23 +47,26 @@ Use these instead: ## Starting Development Services **โš ๏ธ CANONICAL DEVELOPMENT STARTUP:** -```bash -./scripts/dev/start-dev.sh +```powershell +./scripts/dev/start-dev.ps1 ``` ### Available Flags -```bash -./scripts/dev/start-dev.sh # Standard startup -./scripts/dev/start-dev.sh --webadmin # Rebuild WebAdmin container -./scripts/dev/start-dev.sh --clean # Complete reset (removes all volumes) -./scripts/dev/start-dev.sh --build # Force rebuild with --no-cache -./scripts/dev/start-dev.sh --help # Show usage +```powershell +./scripts/dev/start-dev.ps1 # Standard startup +./scripts/dev/start-dev.ps1 -WebAdmin # Rebuild WebAdmin container +./scripts/dev/start-dev.ps1 -Clean # Complete reset (removes all volumes) +./scripts/dev/start-dev.ps1 -Build # Force rebuild (uses cache where possible) +./scripts/dev/start-dev.ps1 -Rebuild # Full rebuild with --no-cache (nuclear option) +./scripts/dev/start-dev.ps1 -Logs -LogService webadmin # Show container logs ``` **Flag Details:** -- `--webadmin`: Restarts WebAdmin container (fixes Next.js issues) -- `--clean`: Removes containers, volumes, node_modules, build artifacts -- `--build`: Rebuilds containers with `--no-cache` flag +- `-WebAdmin`: Restarts WebAdmin container (fixes Next.js issues) +- `-Clean`: Removes containers, volumes, node_modules, build artifacts +- `-Build`: Rebuilds containers (uses cache where possible) +- `-Rebuild`: Full rebuild with `--no-cache` flag (nuclear option) +- `-Logs [-LogService ]`: Show container logs for specific service ## Available Services After startup, these services are available: @@ -93,7 +96,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f [service] ### Development vs Production -| Aspect | Development (`start-dev.sh`) | Production (`docker compose up`) | +| Aspect | Development (`start-dev.ps1`) | Production (`docker compose up`) | |--------|------------------------------|----------------------------------| | WebAdmin Container | `node:22-alpine` with mounted source | Built Next.js app in container | | Hot Reloading | โœ… Enabled via volume mounts | โŒ Static build | @@ -121,65 +124,91 @@ export DOCKER_GROUP_ID=$(id -g) ## Helper Commands -### dev-workflow.sh -```bash -./scripts/dev/dev-workflow.sh logs # WebAdmin logs (real-time) -./scripts/dev/dev-workflow.sh shell # Open shell in container -./scripts/dev/dev-workflow.sh lint-fix-webadmin # ESLint with --fix -./scripts/dev/dev-workflow.sh build-sdks # Build SDKs -./scripts/dev/dev-workflow.sh exec [command] # Execute custom command +### dev-workflow.ps1 +```powershell +# Build Commands +./scripts/dev/dev-workflow.ps1 build-webadmin # Build WebAdmin application +./scripts/dev/dev-workflow.ps1 build-sdks # Build all SDK packages +./scripts/dev/dev-workflow.ps1 build-sdk # Build specific SDK (common|admin|core) + +# Lint/Type Commands +./scripts/dev/dev-workflow.ps1 lint-webadmin # Run ESLint on WebAdmin +./scripts/dev/dev-workflow.ps1 lint-fix-webadmin # Run ESLint with --fix +./scripts/dev/dev-workflow.ps1 type-check-webadmin # Run TypeScript type checking + +# NPM Commands +./scripts/dev/dev-workflow.ps1 npm-install-webadmin # Install WebAdmin dependencies +./scripts/dev/dev-workflow.ps1 npm-install-sdks # Install all SDK dependencies + +# Container Commands +./scripts/dev/dev-workflow.ps1 shell # Open bash shell in container +./scripts/dev/dev-workflow.ps1 logs # Show WebAdmin container logs +./scripts/dev/dev-workflow.ps1 restart-webadmin # Restart WebAdmin container +./scripts/dev/dev-workflow.ps1 status # Show container status +./scripts/dev/dev-workflow.ps1 exec # Execute command in container + +# Local Development +./scripts/dev/dev-workflow.ps1 install-local # Install all dependencies locally +./scripts/dev/dev-workflow.ps1 build-local # Build all TypeScript projects locally +./scripts/dev/dev-workflow.ps1 clean # Clean node_modules and build artifacts ``` ### Other Helper Scripts -- `scripts/dev/fix-webadmin-errors.sh` - Automated TypeScript/ESLint fixes -- `scripts/dev/fix-sdk-errors.sh` - SDK TypeScript compilation fixes -- `scripts/dev/create-webadmin-key.sh` - Create virtual keys for testing +- `scripts/dev/fix-webadmin-errors.ps1` - Automated TypeScript/ESLint fixes + - `-LintOnly` - Run linting and fixing only (skip build) + - `-BuildOnly` - Run build only (skip linting) + - `-CheckOnly` - Check environment and permissions only +- `scripts/dev/fix-sdk-errors.ps1` - SDK TypeScript compilation fixes +- `scripts/dev/create-webadmin-key.ps1` - Create virtual keys for testing +- `scripts/dev/setup-r2-dev.ps1` - Setup Cloudflare R2 development environment - `scripts/test/validate-eslint.sh` - Validate ESLint configuration +- `scripts/test/validate-eslint-strict.sh` - Strict ESLint validation (CI/CD) +- `scripts/migrations/validate-migrations.sh` - Validate EF Core migrations ## Troubleshooting ### Permission Denied Errors -```bash +```powershell # Symptom: npm EACCES errors, cannot write to node_modules -./scripts/dev/start-dev.sh --clean +./scripts/dev/start-dev.ps1 -Clean ``` ### After Adding New Packages -```bash -./scripts/dev/start-dev.sh --webadmin +```powershell +./scripts/dev/start-dev.ps1 -WebAdmin ``` ### Container Conflicts -```bash +```powershell # Symptom: Containers already exist or port conflicts docker compose down --volumes --remove-orphans -./scripts/dev/start-dev.sh --clean +./scripts/dev/start-dev.ps1 -Clean ``` ### Next.js Build Issues / Stale Builds -```bash -./scripts/dev/start-dev.sh --webadmin +```powershell +./scripts/dev/start-dev.ps1 -WebAdmin ``` ### WebAdmin Not Starting -```bash +```powershell # Check logs docker compose -f docker-compose.yml -f docker-compose.dev.yml logs webadmin # Common causes: # 1. Port 3000 already in use # 2. Missing environment variables in .env -# 3. Node modules corruption (use --clean) +# 3. Node modules corruption (use -Clean) ``` ### Hot Reload Not Working -```bash +```powershell # Verify file mounting docker compose -f docker-compose.yml -f docker-compose.dev.yml exec webadmin ls -la /app/WebAdmin/ # Clean host build artifacts (container has isolated .next) -rm -rf WebAdmin/.next -./scripts/dev/start-dev.sh --webadmin +Remove-Item -Recurse -Force WebAdmin/.next +./scripts/dev/start-dev.ps1 -WebAdmin ``` --- @@ -214,7 +243,7 @@ dotnet build ConduitLLM.Admin # Admin API # SDKs cd SDKs/Node/Admin && npm run build -cd SDKs/Node/Core && npm run build +cd SDKs/Node/Gateway && npm run build cd SDKs/Node/Common && npm run build ``` @@ -359,7 +388,10 @@ public enum ProviderType Ultravox = 7, ElevenLabs = 8, // Audio provider Cerebras = 9, // High-performance inference - SambaNova = 10 // Ultra-fast inference + SambaNova = 10, // Ultra-fast inference + DeepInfra = 11, // OpenAI-compatible LLM inference + Cloudflare = 12, // Serverless AI on Cloudflare's global network + OpenRouter = 13 // Multi-provider routing via OpenAI-compatible API } ``` @@ -381,6 +413,14 @@ public enum ProviderType - NOT for end-users or client applications - Configured on WebAdmin service +### Health Monitoring Key +**CONDUIT_HEALTH_MONITORING_KEY**: +- Used by external monitoring services (BetterStack, Pingdom, etc.) to access health endpoints +- Passed via `X-Conduit-Health-Key` header +- Private network requests (10.x, 172.16-31.x, 192.168.x, 127.x) don't require this key +- External requests without valid key receive `404 Not Found` +- See `docs/operations/monitoring/health-checks.md` for configuration details + ## WebAdmin API Architecture **The WebAdmin has only 3 API routes** - relies on client-side SDK usage with ephemeral keys: @@ -407,7 +447,7 @@ public enum ProviderType - Development: S3-compatible storage (configure in .env) - Production: AWS S3 or Cloudflare R2 - **MUST** configure storage provider in Admin API for automatic cleanup -- See `docs/CRITICAL-Media-Cleanup-Configuration.md` +- See `docs/operations/deployment/media-cleanup-configuration.md` ### Cloudflare R2 Specifics - Automatic detection based on service URL @@ -430,7 +470,8 @@ public enum ProviderType - **SignalR** provides real-time updates via WebSockets - Redis backplane for horizontal scaling - Falls back to polling if WebSocket fails -- Hubs: navigation-state, video-generation, image-generation +- 15+ specialized hubs: content-generation, task-tracking, spend-notifications, virtual-key-management, webhook-delivery, usage-analytics, metrics, health-monitoring, security-monitoring, and others +- Hub source: `Services/ConduitLLM.Gateway/Hubs/` and `Services/ConduitLLM.Admin/Hubs/` **See:** `docs/architecture/real-time/streaming-and-websockets.md` @@ -447,45 +488,22 @@ public enum ProviderType # Documentation Index -## Core Development Guides -- **[API Patterns & Best Practices](docs/development/API-PATTERNS-BEST-PRACTICES.md)** - WebAdmin API patterns, SDK usage, error handling -- **[LLM Client Factory Guide](docs/development/llm-client-factory-guide.md)** - Provider client creation patterns -- **[Development Documentation](docs/development/README.md)** - Development guides index - -## Architecture Documentation -- **[Architecture Overview](docs/architecture/README.md)** - Complete architecture index -- **[Provider System](docs/architecture/provider-system/provider-architecture.md)** - Provider design, multi-instance support -- **[Model & Cost Mapping](docs/architecture/provider-system/model-and-cost-mapping.md)** - Cost tracking details -- **[Streaming & WebSockets](docs/architecture/real-time/streaming-and-websockets.md)** - Real-time communication, SSE -- **[Webhook Delivery](docs/architecture/real-time/webhook-delivery.md)** - Distributed delivery, circuit breakers -- **[Async Media Generation](docs/architecture/media-generation/async-media-generation.md)** - Event-driven image/video -- **[Background Services](docs/architecture/patterns/background-services-and-workers.md)** - Worker patterns, distributed locking -- **[Repository & Data Access](docs/architecture/patterns/repository-and-data-access.md)** - EF Core best practices -- **[DTO Guidelines](docs/architecture/data-transfer/dto-guidelines.md)** - Data transfer patterns -- **[Scaling Architecture](docs/architecture/infrastructure/scaling-architecture.md)** - 10,000+ concurrent sessions - -## Operations & Deployment -- **[Operations Documentation](docs/operations/README.md)** - Operations index -- **[SignalR Configuration](docs/operations/signalr/configuration.md)** - Real-time updates, Redis backplane -- **[RabbitMQ Scaling](docs/operations/infrastructure/rabbitmq-scaling.md)** - 1,000+ tasks/min configuration -- **[Redis Resilience](docs/operations/infrastructure/redis-resilience.md)** - Configuration and failover -- **[PostgreSQL Scaling](docs/operations/infrastructure/postgresql-scaling.md)** - Database scaling -- **[HTTP Connection Pooling](docs/operations/infrastructure/http-connection-pooling.md)** - Connection optimization -- **[Provider Health Monitoring](docs/operations/providers/health-monitoring.md)** - Provider status tracking -- **[Provider Usage Mappings](docs/operations/providers/usage-mappings.md)** - Usage tracking config -- **[Deployment Configuration](docs/operations/deployment/DEPLOYMENT-CONFIGURATION.md)** - Production guide -- **[Docker Optimization](docs/operations/deployment/docker-optimization.md)** - Container optimization - -## Media & Storage -- **[Media Cleanup Configuration](docs/CRITICAL-Media-Cleanup-Configuration.md)** - โš ๏ธ CRITICAL - S3/R2 cleanup requirements - -## API Integration Guides -- **[API Guides Index](docs/api-guides/README.md)** - API integration documentation -- **[Gateway API Getting Started](docs/api-guides/core/getting-started.md)** - Gateway API usage -- **[Admin API Getting Started](docs/api-guides/admin/getting-started.md)** - Admin API usage -- **[SignalR Getting Started](docs/api-guides/signalr/getting-started.md)** - Real-time integration -- **[SDK Best Practices](docs/api-guides/sdk/best-practices.md)** - SDK usage patterns -- **[Next.js Integration](docs/api-guides/sdk/nextjs-integration.md)** - WebAdmin SDK integration +The full documentation lives in **[docs/README.md](docs/README.md)**. Key entry points: + +| Topic | Start Here | +|-------|------------| +| API usage | [docs/api-guides/](docs/api-guides/README.md) โ€” Gateway API, Admin API, SDKs, SignalR | +| Architecture | [docs/architecture/](docs/architecture/README.md) โ€” Provider system, patterns, infrastructure | +| Operations | [docs/operations/](docs/operations/README.md) โ€” Deployment, monitoring, runbooks, security | +| Model pricing | [docs/model-pricing/](docs/model-pricing/README.md) โ€” Per-provider pricing reference | +| Development | [docs/development/](docs/development/README.md) โ€” Contributing, API patterns, testing | + +### Frequently Referenced Docs +- **[Provider Architecture](docs/architecture/provider-system/provider-architecture.md)** โ€” Multi-instance provider design +- **[Repository & Data Access](docs/architecture/patterns/repository-and-data-access.md)** โ€” EF Core patterns +- **[API Patterns](docs/development/API-PATTERNS-BEST-PRACTICES.md)** โ€” Backend API conventions +- **[LLM Client Factory](docs/development/llm-client-factory-guide.md)** โ€” Adding LLM providers +- **[Media Cleanup](docs/operations/deployment/media-cleanup-configuration.md)** โ€” S3/R2 cleanup (CRITICAL) --- @@ -514,6 +532,6 @@ public enum ProviderType ## Repository Information -- **GitHub Repository**: knnlabs/Conduit -- **Issues URL**: https://github.com/knnlabs/Conduit/issues -- **Pull Requests URL**: https://github.com/knnlabs/Conduit/pulls +- **GitHub Repository**: nickna/Conduit +- **Issues URL**: https://github.com/nickna/Conduit/issues +- **Pull Requests URL**: https://github.com/nickna/Conduit/pulls diff --git a/Directory.Build.props b/Directory.Build.props index 13c82062e..5f151ed47 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,15 +11,15 @@ - knnlabs.Conduit + nickna.Conduit Conduit - KNN Labs, Inc. - KNN Labs, Inc. + Nick Nassiri + Nick Nassiri Conduit A unified .NET client library for interacting with various LLM providers - ยฉ $([System.DateTime]::UtcNow.Year) KNN Labs, Inc. - https://github.com/knnlabs/Conduit - https://github.com/knnlabs/Conduit + ยฉ $([System.DateTime]::UtcNow.Year) Nick Nassiri + https://github.com/nickna/Conduit + https://github.com/nickna/Conduit git \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md index c678c40f8..5b11e03ec 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -141,7 +141,7 @@ dotnet build ConduitLLM.Admin # Admin API # Build SDKs cd SDKs/Node/Admin && npm run build -cd SDKs/Node/Core && npm run build +cd SDKs/Node/Gateway && npm run build cd SDKs/Node/Common && npm run build ``` @@ -284,6 +284,6 @@ My goal is to be a thoughtful and effective engineering partner. I will adhere t ## Repository Information -- **GitHub Repository**: knnlabs/Conduit -- **Issues URL**: https://github.com/knnlabs/Conduit/issues -- **Pull Requests URL**: https://github.com/knnlabs/Conduit/pulls \ No newline at end of file +- **GitHub Repository**: nickna/Conduit +- **Issues URL**: https://github.com/nickna/Conduit/issues +- **Pull Requests URL**: https://github.com/nickna/Conduit/pulls \ No newline at end of file diff --git a/LICENSE b/LICENSE index 9be2cc4e3..0bbca312e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 KNN Labs, Inc. +Copyright (c) 2026 Nick Nassiri Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9e173279c..655766271 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![ConduitLLM Logo](docs/assets/conduit.png) -[![CodeQL](https://github.com/knnlabs/Conduit/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/knnlabs/Conduit/actions/workflows/codeql-analysis.yml) -[![Build & Test](https://github.com/knnlabs/Conduit/actions/workflows/ci.yml/badge.svg)](https://github.com/knnlabs/Conduit/actions/workflows/ci.yml) +[![CodeQL](https://github.com/nickna/Conduit/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/nickna/Conduit/actions/workflows/codeql-analysis.yml) +[![Build & Test](https://github.com/nickna/Conduit/actions/workflows/ci.yml/badge.svg)](https://github.com/nickna/Conduit/actions/workflows/ci.yml) [![OpenAI Compatible](https://img.shields.io/badge/OpenAI-Compatible-brightgreen.svg)](https://platform.openai.com/docs/api-reference) [![Built with .NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/) [![Docker Ready](https://img.shields.io/badge/Docker-Ready-2496ED)](https://www.docker.com/) @@ -15,11 +15,11 @@ Are you juggling multiple LLM provider APIs in your applications? ConduitLLM sol - **Vendor Independence**: Avoid lock-in to any single LLM provider - **Simplified API Management**: Centralized key management and usage tracking - **Cost Optimization**: Route requests to the most cost-effective or performant models -- **๐Ÿš€ Enterprise Scale**: Built to handle **10,000+ concurrent sessions** with a lean, efficient tech stack ([see scaling architecture](docs/Scaling-Architecture.md)) +- **๐Ÿš€ Enterprise Scale**: Built to handle **10,000+ concurrent sessions** with a lean, efficient tech stack ([see scaling architecture](docs/architecture/infrastructure/scaling-architecture.md)) ## Overview -ConduitLLM is a unified, modular, and extensible platform designed to simplify interaction with multiple Large Language Models (LLMs). It provides a single, consistent OpenAI-compatible REST API endpoint, acting as a gateway or "conduit" to various LLM backends such as OpenAI, Anthropic, Azure OpenAI, Google Gemini, Cohere, and others. +ConduitLLM is a unified, modular, and extensible platform designed to simplify interaction with multiple Large Language Models (LLMs). It provides a single, consistent OpenAI-compatible REST API endpoint, acting as a gateway or "conduit" to various LLM backends such as OpenAI, Groq, Replicate, Fireworks, MiniMax, Cerebras, SambaNova, DeepInfra, Cloudflare Workers AI, and any OpenAI-compatible API. Built with .NET and designed for containerization (Docker), ConduitLLM streamlines the development, deployment, and management of LLM-powered applications by abstracting provider-specific complexities. @@ -57,6 +57,9 @@ npm install @knn_labs/conduit-admin-client - **Web-Based User Interface**: Administrative dashboard for configuration and monitoring - **Enterprise Security Features**: IP filtering, rate limiting, failed login protection, and security headers - **Security Dashboard**: Real-time monitoring of security events and access attempts +- **Function Calling & Tool Execution**: Server-side function execution with configuration management, cost tracking, and agentic mode support +- **Media Generation Webhooks**: Per-request callback notifications with retry logic, circuit breakers, and delivery tracking for async image/video generation +- **Observability & Monitoring**: Built-in Prometheus metrics, pre-built Grafana dashboards, multi-layered health checks, and optional OpenTelemetry tracing - **Centralized Configuration**: Flexible configuration via database, environment variables, or JSON files - **Extensible Architecture**: Easily add support for new LLM providers @@ -127,7 +130,7 @@ environment: CONDUIT_DISABLE_DIRECT_DB_ACCESS: "true" # Completely disable legacy mode ``` -> **Important**: Direct database access mode (`CONDUIT_USE_ADMIN_API=false`) is deprecated and will be removed after October 2025. See [Migration Guide](docs/admin-api-migration-guide.md) for details. +> **Important**: Direct database access mode (`CONDUIT_USE_ADMIN_API=false`) is deprecated and will be removed after October 2025. The WebAdmin includes a built-in health check indicator that monitors the connection to the Admin API: @@ -151,9 +154,9 @@ As of May 2025, ConduitLLM is distributed as three separate Docker images: Each service is built, tagged, and published as an independent container: -- `ghcr.io/knnlabs/conduit-webadmin:latest` (WebAdmin) -- `ghcr.io/knnlabs/conduit-admin:latest` (Admin API) -- `ghcr.io/knnlabs/conduit-http:latest` (API Gateway) +- `ghcr.io/nickna/conduit-webadmin:latest` (WebAdmin) +- `ghcr.io/nickna/conduit-admin:latest` (Admin API) +- `ghcr.io/nickna/conduit-http:latest` (API Gateway) #### Why this architecture? - **Separation of concerns**: Each component can be scaled, deployed, and maintained independently @@ -166,39 +169,58 @@ Each service is built, tagged, and published as an independent container: With Docker Compose: ```yaml -docker-compose.yml +# docker-compose.yml services: webadmin: - image: ghcr.io/knnlabs/conduit-webadmin:latest + image: ghcr.io/nickna/conduit-webadmin:latest ports: - - "5001:8080" + - "3000:3000" environment: CONDUIT_ADMIN_API_BASE_URL: http://admin:8080 + CONDUIT_API_BASE_URL: http://api:8080 CONDUIT_API_TO_API_BACKEND_AUTH_KEY: your_secure_backend_key - CONDUIT_USE_ADMIN_API: "true" - CONDUIT_DISABLE_DIRECT_DB_ACCESS: "true" # Completely disable legacy mode + CONDUIT_API_EXTERNAL_URL: http://localhost:5000 + CONDUIT_ADMIN_API_EXTERNAL_URL: http://localhost:5002 + # Clerk authentication (required for production) + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: your_clerk_publishable_key + CLERK_SECRET_KEY: your_clerk_secret_key depends_on: - admin + - api admin: - image: ghcr.io/knnlabs/conduit-admin:latest + image: ghcr.io/nickna/conduit-admin:latest ports: - "5002:8080" environment: DATABASE_URL: postgresql://conduit:conduitpass@postgres:5432/conduitdb CONDUIT_API_TO_API_BACKEND_AUTH_KEY: your_secure_backend_key + REDIS_URL: redis://redis:6379 + CONDUITLLM__RABBITMQ__HOST: rabbitmq + CONDUITLLM__RABBITMQ__PORT: 5672 + CONDUITLLM__RABBITMQ__USERNAME: conduit + CONDUITLLM__RABBITMQ__PASSWORD: conduitpass depends_on: - postgres + - redis + - rabbitmq - http: - image: ghcr.io/knnlabs/conduit-http:latest + api: + image: ghcr.io/nickna/conduit-http:latest ports: - "5000:8080" environment: DATABASE_URL: postgresql://conduit:conduitpass@postgres:5432/conduitdb + REDIS_URL: redis://redis:6379 + CONDUITLLM__RABBITMQ__HOST: rabbitmq + CONDUITLLM__RABBITMQ__PORT: 5672 + CONDUITLLM__RABBITMQ__USERNAME: conduit + CONDUITLLM__RABBITMQ__PASSWORD: conduitpass depends_on: - postgres + - redis + - rabbitmq postgres: image: postgres:16 @@ -209,11 +231,22 @@ services: volumes: - pgdata:/var/lib/postgresql/data + redis: + image: redis:alpine + ports: + - "6379:6379" + + rabbitmq: + image: rabbitmq:3-management + ports: + - "5672:5672" + - "15672:15672" + volumes: pgdata: ``` -> **Note:** All CI/CD workflows and deployment scripts should be updated to reference the new image tags. See `.github/workflows/docker-release.yml` for examples. +> **Note:** All CI/CD workflows and deployment scripts should be updated to reference the new image tags. See `.github/workflows/release.yml` for examples. ## Database Configuration (PostgreSQL Only) @@ -241,13 +274,13 @@ For more details, see the per-service README files. 1. **Clone the repository** ```bash - git clone https://github.com/knnlabs/Conduit.git - cd Conduit/WebAdmin + git clone https://github.com/nickna/Conduit.git + cd Conduit ``` 2. **Configure LLM Providers** - Add your provider API keys via: - - Environment variables (see `docs/Environment-Variables.md`) + - Environment variables (see [Documentation](docs/README.md)) - Edit `appsettings.json` - Use the WebAdmin after startup @@ -258,15 +291,19 @@ For more details, see the per-service README files. 4. **Access ConduitLLM** - **Local API**: `http://localhost:5000` - - **Local WebAdmin**: `http://localhost:5001` + - **Local WebAdmin**: `http://localhost:3000` - **Local API Docs**: `http://localhost:5000/swagger` (Development Mode) - *Note: When running locally via `./scripts/start-dev.sh`, these are the default ports. When deployed using Docker or other methods, access is typically via an HTTPS reverse proxy. Configure the `CONDUIT_API_BASE_URL` environment variable to the public-facing URL (e.g., `https://conduit.yourdomain.com`) for correct link generation.* + *Note: When running locally via `./scripts/dev/start-dev.ps1`, these are the default ports. When deployed using Docker or other methods, access is typically via an HTTPS reverse proxy. Configure the `CONDUIT_API_BASE_URL` environment variable to the public-facing URL (e.g., `https://conduit.yourdomain.com`) for correct link generation.* ### Docker Installation +Pull the individual service images: + ```bash -docker pull ghcr.io/knnlabs/conduit:latest +docker pull ghcr.io/nickna/conduit-webadmin:latest +docker pull ghcr.io/nickna/conduit-admin:latest +docker pull ghcr.io/nickna/conduit-http:latest ``` Or use with Docker Compose: @@ -275,7 +312,7 @@ Or use with Docker Compose: docker compose up -d ``` -*Note: The default Docker configuration assumes ConduitLLM runs behind a reverse proxy that handles HTTPS termination. The container exposes HTTP ports only.* +*Note: The default Docker configuration assumes ConduitLLM runs behind a reverse proxy that handles HTTPS termination. The containers expose HTTP ports only.* ### Environment Variables @@ -366,7 +403,7 @@ CONDUIT_IP_BAN_DURATION_MINUTES=30 2. **Clerk Authentication** - Configure Clerk publishable and secret keys for WebAdmin authentication 3. **Session security** - Use a strong `SESSION_SECRET` for production deployments -For a complete migration guide from old to new environment variables, see [Environment Variable Migration Guide](docs/MIGRATION_ENV_VARS.md). +For more configuration details, see the [Documentation](docs/README.md). ## Usage @@ -454,65 +491,23 @@ See [Streaming with Tools Guide](docs/api-guides/streaming-with-tools.md) for co ## Documentation -See the `docs/` directory for detailed documentation: - - -### Core Documentation -- [API Reference](docs/API-Reference.md) -- [Architecture Overview](docs/Architecture-Overview.md) - - [Admin API Adapters](docs/Architecture/Admin-API-Adapters.md) - - [DTO Standardization](docs/Architecture/DTO-Standardization.md) - - [Repository Pattern](docs/Architecture/Repository-Pattern.md) -- [๐Ÿš€ Scaling Architecture](docs/Scaling-Architecture.md) - **10,000+ concurrent sessions architecture and roadmap** -- [Getting Started](docs/Getting-Started.md) -- [Current Status](docs/Current-Status.md) - -### Development Guides -- [SDK Migration Guide](docs/development/sdk-migration-guide.md) -- [API Patterns & Best Practices](docs/development/API-PATTERNS-BEST-PRACTICES.md) -- [SDK Migration Complete](docs/development/SDK-MIGRATION-COMPLETE.md) -- [Next.js 15 Migration](docs/development/nextjs15-migration.md) -- [SDK Feature Gaps](docs/development/sdk-gaps.md) - -### Deployment & Configuration -- [Deployment Configuration](docs/deployment/DEPLOYMENT-CONFIGURATION.md) -- [Docker Optimization](docs/deployment/docker-optimization.md) -- [Configuration Guide](docs/Configuration-Guide.md) -- [Environment Variables](docs/Environment-Variables.md) -- [Cache Configuration](docs/Cache-Configuration.md) -- [Distributed Cache Statistics](docs/claude/distributed-cache-statistics.md) - **Horizontal Scaling Guide** - -#### Redis Circuit Breaker (Resilience) +See the **[Documentation Hub](docs/README.md)** for the complete, organized documentation index. Key sections include: + +| Area | Description | +|------|-------------| +| **[API Guides](docs/api-guides/)** | Gateway API, Admin API, SDK integration, function calling | +| **[Architecture](docs/architecture/)** | System design, provider system, real-time features | +| **[Development](docs/development/)** | Contributing, API patterns, LLM client factory | +| **[Operations](docs/operations/)** | Monitoring, scaling, runbooks, security | +| **[Model Pricing](docs/model-pricing/)** | Cost information across providers | + +### Redis Circuit Breaker (Resilience) ConduitLLM includes automatic circuit breaker protection for Redis operations: - **Automatic failure detection**: Opens circuit after 5 consecutive failures or 50% failure rate - **Service protection**: Returns 503 Service Unavailable when Redis is down (30-second recovery period) - **Health monitoring**: Circuit state exposed via `/health` endpoint under `redis_circuit_breaker` - **Manual control**: Emergency trip/reset available via environment variable `REDIS_CIRCUIT_BREAKER_ENABLE_MANUAL_CONTROL=true` -### API Reference -- [API Reference](docs/api-reference/API-REFERENCE.md) -- [Admin API Migration Guide](docs/admin-api-migration-guide.md) - -### Examples & Integration -- [Integration Examples](docs/examples/INTEGRATION-EXAMPLES.md) -- [OpenAI Compatible Example](docs/examples/openai-compatible-example.md) - -### Troubleshooting -- [Troubleshooting Guide](docs/troubleshooting/TROUBLESHOOTING-GUIDE.md) - -### Feature Documentation -- [Budget Management](docs/Budget-Management.md) -- [Dashboard Features](docs/Dashboard-Features.md) -- [LLM Routing](docs/LLM-Routing.md) -- [Multimodal Vision Support](docs/Multimodal-Vision-Support.md) -- [Provider Integration](docs/Provider-Integration.md) -- [Virtual Keys](docs/Virtual-Keys.md) -- [WebAdmin Guide](docs/WebAdmin-Guide.md) - -### Project Documentation -- [SDK Integration Epic](docs/epics/sdk-integration.md) -- [Archived Documentation](docs/archive/webadmin-migration/) - ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/SDKs/Node/Admin/README.md b/SDKs/Node/Admin/README.md index 5d8f0965f..156fe6e75 100755 --- a/SDKs/Node/Admin/README.md +++ b/SDKs/Node/Admin/README.md @@ -352,5 +352,5 @@ MIT - see LICENSE file for details ## Support -- GitHub Issues: https://github.com/knnlabs/Conduit/issues -- Documentation: https://github.com/knnlabs/Conduit/tree/master/SDKs/Node/Admin +- GitHub Issues: https://github.com/nickna/Conduit/issues +- Documentation: https://github.com/nickna/Conduit/tree/master/SDKs/Node/Admin diff --git a/SDKs/Node/Admin/RELEASE.md b/SDKs/Node/Admin/RELEASE.md index 5734fc5f2..4ae91dfe2 100755 --- a/SDKs/Node/Admin/RELEASE.md +++ b/SDKs/Node/Admin/RELEASE.md @@ -92,7 +92,7 @@ npm install conduit-admin-client@1.2.3 ## Monitoring - **NPM Package**: https://www.npmjs.com/package/conduit-admin-client -- **GitHub Releases**: https://github.com/knnlabs/Conduit/releases +- **GitHub Releases**: https://github.com/nickna/Conduit/releases - **Workflow Status**: Actions tab in GitHub repository ## Troubleshooting diff --git a/SDKs/Node/Admin/package.json b/SDKs/Node/Admin/package.json index 84b075d85..a9dc16bb8 100755 --- a/SDKs/Node/Admin/package.json +++ b/SDKs/Node/Admin/package.json @@ -60,21 +60,21 @@ }, "dependencies": { "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" + "@microsoft/signalr": "^10.0.0" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@types/react": "^19.1.8", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.0.4", - "prettier": "^3.0.0", - "ts-jest": "^29.1.0", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^9.39.2", + "jest": "^30.2.0", + "prettier": "^3.7.4", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "tsup": "^8.0.0", - "typescript": "^5.8.3" + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "engines": { "node": ">=16.0.0" @@ -85,11 +85,11 @@ }, "repository": { "type": "git", - "url": "https://github.com/knnlabs/Conduit.git", + "url": "https://github.com/nickna/Conduit.git", "directory": "SDKs/Node/Admin" }, - "homepage": "https://github.com/knnlabs/Conduit/tree/master/SDKs/Node/Admin", + "homepage": "https://github.com/nickna/Conduit/tree/master/SDKs/Node/Admin", "bugs": { - "url": "https://github.com/knnlabs/Conduit/issues" + "url": "https://github.com/nickna/Conduit/issues" } } diff --git a/SDKs/Node/Admin/src/FetchConduitAdminClient.ts b/SDKs/Node/Admin/src/FetchConduitAdminClient.ts index fbda23820..055b3f2c8 100755 --- a/SDKs/Node/Admin/src/FetchConduitAdminClient.ts +++ b/SDKs/Node/Admin/src/FetchConduitAdminClient.ts @@ -7,7 +7,6 @@ import { FetchSystemService } from './services/FetchSystemService'; import { FetchModelMappingsService } from './services/FetchModelMappingsService'; import { FetchSettingsService } from './services/FetchSettingsService'; import { FetchAnalyticsService } from './services/FetchAnalyticsService'; -import { FetchSecurityService } from './services/FetchSecurityService'; import { FetchConfigurationService } from './services/FetchConfigurationService'; import { FetchMonitoringService } from './services/FetchMonitoringService'; import { FetchIpFilterService } from './services/FetchIpFilterService'; @@ -64,7 +63,6 @@ export class FetchConduitAdminClient extends FetchBaseApiClient { public readonly modelMappings: FetchModelMappingsService; public readonly settings: FetchSettingsService; public readonly analytics: FetchAnalyticsService; - public readonly security: FetchSecurityService; public readonly configuration: FetchConfigurationService; public readonly monitoring: FetchMonitoringService; public readonly ipFilters: FetchIpFilterService; @@ -96,7 +94,6 @@ export class FetchConduitAdminClient extends FetchBaseApiClient { this.modelMappings = new FetchModelMappingsService(this); this.settings = new FetchSettingsService(this); this.analytics = new FetchAnalyticsService(this); - this.security = new FetchSecurityService(this); this.configuration = new FetchConfigurationService(this); this.monitoring = new FetchMonitoringService(this); this.ipFilters = new FetchIpFilterService(this); diff --git a/SDKs/Node/Admin/src/constants.ts b/SDKs/Node/Admin/src/constants.ts index fd51ff6d0..7718cbd23 100755 --- a/SDKs/Node/Admin/src/constants.ts +++ b/SDKs/Node/Admin/src/constants.ts @@ -210,6 +210,11 @@ export const ENDPOINTS = { DOWNLOAD: (backupId: string) => `/api/database/download/${backupId}`, }, + // Prompt Caching + PROMPT_CACHING: { + CONFIG: '/api/prompt-caching/config', + }, + // Configuration endpoints CONFIG: { ROUTING: '/api/config/routing', @@ -245,13 +250,6 @@ export const ENDPOINTS = { FALLBACK_BY_MODEL: (primaryModel: string) => `/api/Router/fallbacks/${primaryModel}`, }, - // Security endpoints - SECURITY: { - EVENTS: '/api/security/events', - THREATS: '/api/security/threats', - COMPLIANCE: '/api/security/compliance', - }, - // System SYSTEM: { INFO: '/api/SystemInfo/info', diff --git a/SDKs/Node/Admin/src/index.ts b/SDKs/Node/Admin/src/index.ts index 50b10f5c1..7ef0aec54 100755 --- a/SDKs/Node/Admin/src/index.ts +++ b/SDKs/Node/Admin/src/index.ts @@ -27,6 +27,7 @@ export * from './models/settings'; export * from './models/ipFilter'; export * from './models/media'; export * from './models/functions'; +export * from './models/promptCaching'; // Re-export model types except ModelCapabilities (conflicts with providerModels) export { ModelType, @@ -85,26 +86,6 @@ export * from './models/databaseBackup'; export * from './models/signalr'; // notifications model removed - was only used by deleted SignalR services export * from './models/monitoring'; -export * from './models/security'; -// Re-export securityExtended types except ExportParams and ExportResult (conflicts with analytics) -export { - IpWhitelistDto, - IpEntry, - SecurityEventParams, - SecurityEventType, - SecurityEventExtended, - SecurityEventPage, - ThreatSummaryDto, - ThreatCategory, - ActiveThreat, - AccessPolicy, - PolicyRule, - CreateAccessPolicyDto, - UpdateAccessPolicyDto, - AuditLogParams, - AuditLog, - AuditLogPage, -} from './models/securityExtended'; export * from './models/configuration'; // Re-export configurationExtended types except RoutingRule and UpdateRoutingConfigDto (conflicts with configuration) export { @@ -165,7 +146,6 @@ export { FetchModelMappingsService } from './services/FetchModelMappingsService' export { FetchSettingsService } from './services/FetchSettingsService'; export type { SettingUpdate, SettingsDto, SettingsListResponseDto } from './services/FetchSettingsService'; export { FetchAnalyticsService } from './services/FetchAnalyticsService'; -export { FetchSecurityService } from './services/FetchSecurityService'; export { FetchConfigurationService } from './services/FetchConfigurationService'; export { FetchMonitoringService } from './services/FetchMonitoringService'; export { FetchIpFilterService } from './services/FetchIpFilterService'; @@ -202,6 +182,7 @@ export * from './models/pricing'; // Utilities export * from './utils/errors'; +export * from './utils/costFormatters'; // Models export * from './models/metadata'; diff --git a/SDKs/Node/Admin/src/models/common-types.ts b/SDKs/Node/Admin/src/models/common-types.ts index 4b6ee2d2c..e15f03095 100755 --- a/SDKs/Node/Admin/src/models/common-types.ts +++ b/SDKs/Node/Admin/src/models/common-types.ts @@ -387,4 +387,24 @@ export type AdditionalProviderInfo = { features?: string[]; limits?: Record; [key: string]: unknown; -}; \ No newline at end of file +}; + +/** + * Paged result for paginated queries + */ +export interface PagedResult { + /** Array of items in the current page */ + items: T[]; + + /** Total number of items across all pages */ + totalCount: number; + + /** Current page number */ + page: number; + + /** Number of items per page */ + pageSize: number; + + /** Total number of pages */ + totalPages: number; +} \ No newline at end of file diff --git a/SDKs/Node/Admin/src/models/promptCaching.ts b/SDKs/Node/Admin/src/models/promptCaching.ts new file mode 100644 index 000000000..160a95181 --- /dev/null +++ b/SDKs/Node/Admin/src/models/promptCaching.ts @@ -0,0 +1,14 @@ +export interface PromptCachingConfigDto { + autoInjectEnabled: boolean; + injectionPoints: CacheInjectionPointDto[]; +} + +export interface UpdatePromptCachingConfigDto { + autoInjectEnabled: boolean; + injectionPoints: CacheInjectionPointDto[]; +} + +export interface CacheInjectionPointDto { + role?: string | null; + index?: number | null; +} diff --git a/SDKs/Node/Admin/src/models/providerConfiguration.ts b/SDKs/Node/Admin/src/models/providerConfiguration.ts index 9c1af83c3..b59e3c0d1 100644 --- a/SDKs/Node/Admin/src/models/providerConfiguration.ts +++ b/SDKs/Node/Admin/src/models/providerConfiguration.ts @@ -19,6 +19,8 @@ export const PROVIDER_DISPLAY_NAMES: Record = { [ProviderType.Cerebras]: 'Cerebras', [ProviderType.SambaNova]: 'SambaNova Cloud', [ProviderType.DeepInfra]: 'DeepInfra', + [ProviderType.Cloudflare]: 'Cloudflare Workers AI', + [ProviderType.OpenRouter]: 'OpenRouter', }; /** Provider categories for grouping in UI */ @@ -44,6 +46,8 @@ export const PROVIDER_CATEGORIES: Record = { [ProviderType.Cerebras]: [ProviderCategory.Chat], [ProviderType.SambaNova]: [ProviderCategory.Chat], [ProviderType.DeepInfra]: [ProviderCategory.Chat, ProviderCategory.Image, ProviderCategory.Embedding], + [ProviderType.Cloudflare]: [ProviderCategory.Chat, ProviderCategory.Embedding, ProviderCategory.Image], + [ProviderType.OpenRouter]: [ProviderCategory.Chat], }; /** Provider-specific configuration requirements */ @@ -154,6 +158,24 @@ export const PROVIDER_CONFIG_REQUIREMENTS: Record; - - /** Threat trend over time */ - threatTrend: Array<{ - /** Date of the data point */ - date: string; - - /** Number of threats on that date */ - count: number; - }>; -} - -/** - * Compliance metrics for the system - */ -export interface ComplianceMetrics { - /** Overall compliance score (0-100) */ - overallScore: number; - - /** Compliance scores by category */ - categories: { - /** Data protection compliance score */ - dataProtection: number; - - /** Access control compliance score */ - accessControl: number; - - /** Audit logging compliance score */ - auditLogging: number; - - /** Incident response compliance score */ - incidentResponse: number; - - /** Monitoring compliance score */ - monitoring: number; - }; - - /** Timestamp of the last compliance assessment */ - lastAssessment: string; - - /** List of compliance issues */ - issues: Array<{ - /** Category of the issue */ - category: string; - - /** Severity of the issue */ - severity: string; - - /** Description of the issue */ - description: string; - }>; -} - -/** - * Paged result for security-related queries - */ -export interface PagedResult { - /** Array of items in the current page */ - items: T[]; - - /** Total number of items across all pages */ - totalCount: number; - - /** Current page number */ - page: number; - - /** Number of items per page */ - pageSize: number; - - /** Total number of pages */ - totalPages: number; -} - -/** - * Actions that can be taken on a threat - */ -export type ThreatAction = 'acknowledge' | 'resolve' | 'ignore'; - -/** - * Export formats supported by the security service - */ -export type ExportFormat = 'json' | 'csv' | 'pdf'; \ No newline at end of file diff --git a/SDKs/Node/Admin/src/models/securityExtended.ts b/SDKs/Node/Admin/src/models/securityExtended.ts deleted file mode 100755 index b4611ad7a..000000000 --- a/SDKs/Node/Admin/src/models/securityExtended.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { FilterOptions } from './common'; -import type { ExtendedMetadata, ConfigValue, SecurityChangeRecord } from './common-types'; - -// IP Management types -export interface IpWhitelistDto { - enabled: boolean; - ips: IpEntry[]; - lastModified: string; - totalBlocked: number; -} - -export interface IpEntry { - ip: string; - cidr?: string; - description?: string; - addedBy: string; - addedAt: string; - lastSeen?: string; -} - -// Extended Security Event types -export interface SecurityEventParams extends FilterOptions { - startDate?: string; - endDate?: string; - severity?: 'low' | 'medium' | 'high' | 'critical'; - type?: SecurityEventType; - status?: 'active' | 'acknowledged' | 'resolved'; -} - -export type SecurityEventType = - | 'suspicious_activity' - | 'rate_limit_exceeded' - | 'invalid_key_attempt' - | 'ip_blocked' - | 'unusual_usage_pattern' - | 'potential_breach' - | 'policy_violation'; - -export interface SecurityEventExtended { - id: string; - type: SecurityEventType; - severity: 'low' | 'medium' | 'high' | 'critical'; - title: string; - description: string; - source: { - ip?: string; - virtualKeyId?: string; - userId?: string; - }; - timestamp: string; - status: 'active' | 'acknowledged' | 'resolved'; - metadata?: ExtendedMetadata; -} - -export interface SecurityEventPage { - items: SecurityEventExtended[]; - totalCount: number; - page: number; - pageSize: number; - totalPages: number; -} - -// Threat Detection types -export interface ThreatSummaryDto { - threatLevel: 'low' | 'medium' | 'high' | 'critical'; - activeThreats: number; - blockedAttempts24h: number; - suspiciousActivities24h: number; - topThreats: ThreatCategory[]; -} - -export interface ThreatCategory { - category: string; - count: number; - severity: 'low' | 'medium' | 'high' | 'critical'; - trend: 'increasing' | 'stable' | 'decreasing'; -} - -export interface ActiveThreat { - id: string; - type: string; - severity: 'low' | 'medium' | 'high' | 'critical'; - source: string; - firstDetected: string; - lastActivity: string; - attemptCount: number; - status: 'monitoring' | 'blocking' | 'mitigated'; - recommendedAction?: string; -} - -// Access Control types -export interface AccessPolicy { - id: string; - name: string; - description?: string; - type: 'ip_based' | 'key_based' | 'rate_limit' | 'custom'; - rules: PolicyRule[]; - enabled: boolean; - priority: number; - createdAt: string; - updatedAt: string; -} - -export interface PolicyRule { - condition: { - field: string; - operator: 'equals' | 'contains' | 'gt' | 'lt' | 'regex'; - value: ConfigValue; - }; - action: 'allow' | 'deny' | 'limit' | 'log'; - metadata?: ExtendedMetadata; -} - -export interface CreateAccessPolicyDto { - name: string; - description?: string; - type: 'ip_based' | 'key_based' | 'rate_limit' | 'custom'; - rules: PolicyRule[]; - enabled?: boolean; - priority?: number; -} - -export interface UpdateAccessPolicyDto { - name?: string; - description?: string; - rules?: PolicyRule[]; - enabled?: boolean; - priority?: number; -} - -// Audit Log types -export interface AuditLogParams extends FilterOptions { - startDate?: string; - endDate?: string; - action?: string; - userId?: string; - resourceType?: string; - resourceId?: string; -} - -export interface AuditLog { - id: string; - timestamp: string; - userId: string; - action: string; - resourceType: string; - resourceId?: string; - changes?: SecurityChangeRecord[]; - ipAddress?: string; - userAgent?: string; - result: 'success' | 'failure'; - errorMessage?: string; -} - -export interface AuditLogPage { - items: AuditLog[]; - totalCount: number; - page: number; - pageSize: number; - totalPages: number; -} - -// Export types -export interface ExportParams { - format: 'json' | 'csv' | 'pdf'; - startDate?: string; - endDate?: string; - includeMetadata?: boolean; -} - -export interface ExportResult { - exportId: string; - status: 'pending' | 'processing' | 'completed' | 'failed'; - downloadUrl?: string; - expiresAt?: string; - error?: string; -} \ No newline at end of file diff --git a/SDKs/Node/Admin/src/services/FetchConfigurationService.ts b/SDKs/Node/Admin/src/services/FetchConfigurationService.ts index 22dfe1dc5..5fb93e30f 100755 --- a/SDKs/Node/Admin/src/services/FetchConfigurationService.ts +++ b/SDKs/Node/Admin/src/services/FetchConfigurationService.ts @@ -1,6 +1,7 @@ import type { FetchBaseApiClient } from '../client/FetchBaseApiClient'; import type { RequestConfig } from '../client/types'; import type { LLMCacheControlDto, ToggleLLMCacheRequest } from '../models/cache-types'; +import type { PromptCachingConfigDto, UpdatePromptCachingConfigDto } from '../models/promptCaching'; import { ENDPOINTS } from '../constants'; /** @@ -172,4 +173,30 @@ export class FetchConfigurationService { ); } + async getPromptCachingConfig(config?: RequestConfig): Promise { + return this.client['get']( + ENDPOINTS.PROMPT_CACHING.CONFIG, + { + signal: config?.signal, + timeout: config?.timeout, + headers: config?.headers, + } + ); + } + + async updatePromptCachingConfig( + data: UpdatePromptCachingConfigDto, + config?: RequestConfig + ): Promise { + return this.client['put']( + ENDPOINTS.PROMPT_CACHING.CONFIG, + data, + { + signal: config?.signal, + timeout: config?.timeout, + headers: config?.headers, + } + ); + } + } \ No newline at end of file diff --git a/SDKs/Node/Admin/src/services/FetchModelCostService.ts b/SDKs/Node/Admin/src/services/FetchModelCostService.ts index 099e513d5..7d4cffa93 100755 --- a/SDKs/Node/Admin/src/services/FetchModelCostService.ts +++ b/SDKs/Node/Admin/src/services/FetchModelCostService.ts @@ -11,7 +11,7 @@ import { UpdateModelCostMappingDto, ModelCostMappingDto, } from '../models/modelCost'; -import { PagedResult } from '../models/security'; +import { PagedResult } from '../models/common-types'; import { ValidationError } from '../utils/errors'; import { validateRequired, validateStringLength, validateNonEmptyArray, validateNumberRange } from '../utils/validation'; diff --git a/SDKs/Node/Admin/src/services/FetchModelService.ts b/SDKs/Node/Admin/src/services/FetchModelService.ts index bba8e14e8..2e4c3fb8c 100644 --- a/SDKs/Node/Admin/src/services/FetchModelService.ts +++ b/SDKs/Node/Admin/src/services/FetchModelService.ts @@ -266,10 +266,10 @@ export class FetchModelService { } /** - * Get models with their provider mapping status and details - * This is a helper method that checks which models have provider mappings + * Get models with their provider mapping status and details. + * Uses identifiers already included in the model response (single API call). */ - async listWithMappingStatus(config?: RequestConfig): Promise; }>> { - // Get all models const models = await this.list(config); - - // Check each model for provider mappings in parallel - const modelsWithStatus = await Promise.all( - models.map(async (model) => { - if (!model.id) { - return { - ...model, - hasProviderMappings: false, - providerCount: 0, - providers: [] - }; - } - - try { - const identifiers = await this.getIdentifiers(model.id, config); - return { - ...model, - hasProviderMappings: identifiers.length > 0, - providerCount: identifiers.length, - providers: identifiers - }; - } catch { - // If there's an error getting identifiers, assume no mappings - return { - ...model, - hasProviderMappings: false, - providerCount: 0, - providers: [] - }; - } - }) - ); - - return modelsWithStatus; + + return models.map(model => { + const identifiers = (model as ModelDto & { identifiers?: Array<{ + id: number; + identifier: string; + provider: number | null; + isPrimary: boolean; + }> }).identifiers ?? []; + + const providers = identifiers.map(i => { + const normalizedProvider = i.provider ? i.provider as ProviderType : null; + return { + id: i.id, + identifier: i.identifier, + provider: i.provider, + isPrimary: i.isPrimary, + normalizedProvider: normalizedProvider ?? null, + providerName: normalizedProvider ? getProviderTypeName(normalizedProvider) : null + }; + }); + + return { + ...model, + hasProviderMappings: providers.length > 0, + providerCount: providers.length, + providers + }; + }); + } + + /** + * Get models with server-side pagination, search, and filtering. + * Returns a paginated result with total count for UI pagination. + */ + async listPaginated(options: { + page?: number; + pageSize?: number; + search?: string; + capability?: string; + hasProviders?: boolean; + } = {}, config?: RequestConfig): Promise<{ + items: Array; + }>; + totalCount: number; + currentPage: number; + pageSize: number; + totalPages: number; + }> { + const params = new URLSearchParams(); + if (options.page !== undefined) params.set('page', String(options.page)); + if (options.pageSize !== undefined) params.set('pageSize', String(options.pageSize)); + if (options.search) params.set('search', options.search); + if (options.capability) params.set('capability', options.capability); + if (options.hasProviders !== undefined) params.set('hasProviders', String(options.hasProviders)); + + const queryString = params.toString(); + const url = queryString ? `${ENDPOINTS.MODELS.BASE}?${queryString}` : ENDPOINTS.MODELS.BASE; + + const response = await this.client['get']<{ + items: ModelDto[]; + totalCount: number; + currentPage: number; + pageSize: number; + totalPages: number; + }>(url, { + signal: config?.signal, + timeout: config?.timeout, + headers: config?.headers, + }); + + // Enrich items with provider mapping status from included identifiers + const items = response.items.map(model => { + const identifiers = (model as ModelDto & { identifiers?: Array<{ + id: number; + identifier: string; + provider: number | null; + isPrimary: boolean; + }> }).identifiers ?? []; + + const providers = identifiers.map(i => { + const normalizedProvider = i.provider ? i.provider as ProviderType : null; + return { + id: i.id, + identifier: i.identifier, + provider: i.provider, + isPrimary: i.isPrimary, + normalizedProvider: normalizedProvider ?? null, + providerName: normalizedProvider ? getProviderTypeName(normalizedProvider) : null + }; + }); + + return { + ...model, + hasProviderMappings: providers.length > 0, + providerCount: providers.length, + providers + }; + }); + + return { + items, + totalCount: response.totalCount, + currentPage: response.currentPage, + pageSize: response.pageSize, + totalPages: response.totalPages + }; } /** diff --git a/SDKs/Node/Admin/src/services/FetchPricingService.ts b/SDKs/Node/Admin/src/services/FetchPricingService.ts index bda4d572f..e16a0ceed 100644 --- a/SDKs/Node/Admin/src/services/FetchPricingService.ts +++ b/SDKs/Node/Admin/src/services/FetchPricingService.ts @@ -1,6 +1,6 @@ import type { FetchBaseApiClient } from '../client/FetchBaseApiClient'; import type { RequestConfig } from '../client/types'; -import { PagedResult } from '../models/security'; +import { PagedResult } from '../models/common-types'; import { PricingRulesConfig, PricingValidationResult, diff --git a/SDKs/Node/Admin/src/services/FetchProvidersService.ts b/SDKs/Node/Admin/src/services/FetchProvidersService.ts index 4958ac6e7..9bf580f56 100755 --- a/SDKs/Node/Admin/src/services/FetchProvidersService.ts +++ b/SDKs/Node/Admin/src/services/FetchProvidersService.ts @@ -120,11 +120,11 @@ export class FetchProvidersService { } /** - * Get all providers with optional pagination + * Get all providers with pagination */ async list( page: number = 1, - pageSize: number = 10, + pageSize: number = 50, config?: RequestConfig ): Promise { const params = new URLSearchParams({ @@ -132,8 +132,8 @@ export class FetchProvidersService { pageSize: pageSize.toString(), }); - // The backend returns an array directly, not a paginated response - const response = await this.client['get']( + // Backend returns a paginated response with items, totalCount, etc. + return this.client['get']( `${ENDPOINTS.PROVIDERS.BASE}?${params.toString()}`, { signal: config?.signal, @@ -141,15 +141,6 @@ export class FetchProvidersService { headers: config?.headers, } ); - - // Convert array response to expected paginated format - return { - items: response, - totalCount: response.length, - page: page, - pageSize: pageSize, - totalPages: Math.ceil(response.length / pageSize) - }; } /** diff --git a/SDKs/Node/Admin/src/services/FetchSecurityService.ts b/SDKs/Node/Admin/src/services/FetchSecurityService.ts deleted file mode 100755 index 7ffc1e52d..000000000 --- a/SDKs/Node/Admin/src/services/FetchSecurityService.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { FetchBaseApiClient } from '../client/FetchBaseApiClient'; -import type { RequestConfig } from '../client/types'; -import { ENDPOINTS } from '../constants'; -import type { - SecurityEvent, - SecurityEventFilters, - ThreatDetection, - PagedResult, -} from '../models/security'; - -/** - * Service for security-related operations - * NOTE: This service has limited functionality. Most security endpoints have been removed. - */ -export class FetchSecurityService { - constructor(private readonly client: FetchBaseApiClient) {} - - /** - * Get security events with optional filtering - */ - async getEvents( - filter?: SecurityEventFilters, - config?: RequestConfig - ): Promise> { - const queryParams = new URLSearchParams(); - if (filter) { - Object.entries(filter).forEach(([key, value]) => { - if (value !== undefined) { - queryParams.append(key, String(value)); - } - }); - } - - const url = queryParams.toString() - ? `${ENDPOINTS.SECURITY.EVENTS}?${queryParams.toString()}` - : ENDPOINTS.SECURITY.EVENTS; - - return this.client['get']>(url, { - signal: config?.signal, - timeout: config?.timeout, - headers: config?.headers, - }); - } - - /** - * Get threat detection status - */ - async getThreats(config?: RequestConfig): Promise { - return this.client['get']( - ENDPOINTS.SECURITY.THREATS, - { - signal: config?.signal, - timeout: config?.timeout, - headers: config?.headers, - } - ); - } - - /** - * Get compliance status - */ - async getComplianceStatus(config?: RequestConfig): Promise { - return this.client['get']( - ENDPOINTS.SECURITY.COMPLIANCE, - { - signal: config?.signal, - timeout: config?.timeout, - headers: config?.headers, - } - ); - } -} \ No newline at end of file diff --git a/SDKs/Node/Admin/src/services/FetchVirtualKeyGroupService.ts b/SDKs/Node/Admin/src/services/FetchVirtualKeyGroupService.ts index 22c358010..9315ad1a2 100644 --- a/SDKs/Node/Admin/src/services/FetchVirtualKeyGroupService.ts +++ b/SDKs/Node/Admin/src/services/FetchVirtualKeyGroupService.ts @@ -10,7 +10,17 @@ import type { VirtualKeyGroupTransactionDto, TransactionHistoryParams } from '../models/virtualKey'; -import type { PagedResult } from '../models/security'; +import type { PagedResult } from '../models/common-types'; + +/** + * Parameters for listing virtual key groups + */ +export interface ListGroupsParams { + /** Page number (1-based, default: 1) */ + page?: number; + /** Number of items per page (default: 50, max: 100) */ + pageSize?: number; +} /** * Type-safe Virtual Key Group service using native fetch @@ -19,11 +29,22 @@ export class FetchVirtualKeyGroupService { constructor(private readonly client: FetchBaseApiClient) {} /** - * Get all virtual key groups + * Get all virtual key groups with pagination */ - async list(config?: RequestConfig): Promise { - return this.client['get']( - ENDPOINTS.VIRTUAL_KEY_GROUPS, + async list(params?: ListGroupsParams, config?: RequestConfig): Promise> { + const queryParams = new URLSearchParams(); + if (params?.page !== undefined) { + queryParams.append('page', params.page.toString()); + } + if (params?.pageSize !== undefined) { + queryParams.append('pageSize', params.pageSize.toString()); + } + + const queryString = queryParams.toString(); + const url = `${ENDPOINTS.VIRTUAL_KEY_GROUPS}${queryString ? `?${queryString}` : ''}`; + + return this.client['get']>( + url, { signal: config?.signal, timeout: config?.timeout, diff --git a/SDKs/Node/Admin/src/types/providers.ts b/SDKs/Node/Admin/src/types/providers.ts index 00ef0ed8e..bce14fb1e 100644 --- a/SDKs/Node/Admin/src/types/providers.ts +++ b/SDKs/Node/Admin/src/types/providers.ts @@ -125,6 +125,24 @@ export const PROVIDER_REGISTRY: Record = { supportsQualityScore: true, supportsVariation: false, description: 'ElevenLabs audio synthesis' + }, + [ProviderType.Cloudflare]: { + value: ProviderType.Cloudflare, + name: 'Cloudflare', + label: 'Cloudflare Workers AI', + supportsSpeedScore: true, + supportsQualityScore: true, + supportsVariation: false, + description: 'Cloudflare Workers AI serverless inference' + }, + [ProviderType.OpenRouter]: { + value: ProviderType.OpenRouter, + name: 'OpenRouter', + label: 'OpenRouter', + supportsSpeedScore: true, + supportsQualityScore: true, + supportsVariation: true, + description: 'OpenRouter multi-provider routing' } }; @@ -197,7 +215,12 @@ export function normalizeProviderType(provider: string | number): ProviderType | 'elevenlabs': ProviderType.ElevenLabs, 'eleven-labs': ProviderType.ElevenLabs, 'sambanova': ProviderType.SambaNova, - 'samba-nova': ProviderType.SambaNova + 'samba-nova': ProviderType.SambaNova, + 'cloudflare': ProviderType.Cloudflare, + 'workers-ai': ProviderType.Cloudflare, + 'workersai': ProviderType.Cloudflare, + 'openrouter': ProviderType.OpenRouter, + 'open-router': ProviderType.OpenRouter }; const lowerProvider = provider.toLowerCase().replace(/[\s_]/g, ''); diff --git a/SDKs/Node/Admin/src/utils/costFormatters.ts b/SDKs/Node/Admin/src/utils/costFormatters.ts new file mode 100644 index 000000000..32cd33eb1 --- /dev/null +++ b/SDKs/Node/Admin/src/utils/costFormatters.ts @@ -0,0 +1,141 @@ +/** + * Cost formatting utilities for model pricing display. + * + * These encode Conduit's business rules for displaying costs across + * different model types (chat, embedding, image, video, audio). + */ + +import { ModelType } from '../models/modelType'; + +/** + * Minimal interface for cost display โ€” accepts any object that has + * the relevant cost fields (works with ModelCostDto and similar shapes). + */ +export interface CostDisplayFields { + modelType: ModelType; + inputCostPerMillionTokens?: number; + outputCostPerMillionTokens?: number; + embeddingCostPerMillionTokens?: number; + imageCostPerImage?: number; + videoCostPerSecond?: number; +} + +/** Format a cost value as "per million tokens" โ€” e.g., "$2.50" */ +export function formatCostPerMillionTokens(cost?: number): string { + if (!cost) return '-'; + return `$${cost.toFixed(2)}`; +} + +/** Format a cost value as "per thousand tokens" (divides by 1000) โ€” e.g., "$0.003" */ +export function formatCostPerThousandTokens(cost?: number): string { + if (!cost) return '-'; + return `$${(cost / 1000).toFixed(3)}`; +} + +/** Format a cost value as "per image" โ€” e.g., "$0.0400" */ +export function formatCostPerImage(cost?: number): string { + if (!cost) return '-'; + return `$${cost.toFixed(4)}`; +} + +/** Format a cost value as "per minute" โ€” e.g., "$0.0060" */ +export function formatCostPerMinute(cost?: number): string { + if (!cost) return '-'; + return `$${cost.toFixed(4)}`; +} + +/** Format a cost value as "per second" โ€” e.g., "$0.000500" */ +export function formatCostPerSecond(cost?: number): string { + if (!cost) return '-'; + return `$${cost.toFixed(6)}`; +} + +/** Format a cost value as "per request" โ€” e.g., "$0.000100" */ +export function formatCostPerRequest(cost?: number): string { + if (!cost) return '-'; + return `$${cost.toFixed(6)}`; +} + +/** Format a ModelType enum value as a display string */ +export function formatModelType(type: ModelType): string { + switch (type) { + case ModelType.Chat: + return 'Chat'; + case ModelType.Embedding: + return 'Embedding'; + case ModelType.Image: + return 'Image'; + case ModelType.Video: + return 'Video'; + case ModelType.Audio: + return 'Audio'; + default: + return type; + } +} + +/** Format a priority number as a human-readable label */ +export function formatPriority(priority: number): string { + if (priority === 0) return 'Default'; + if (priority > 0) return `High (${priority})`; + return `Low (${Math.abs(priority)})`; +} + +/** Format an ISO date string for simple display */ +export function formatDateString(dateString: string): string { + return new Date(dateString).toLocaleDateString(); +} + +/** Annotate a model pattern with "(Pattern)" when it contains wildcards */ +export function formatModelPattern(pattern: string): string { + if (pattern.includes('*')) { + return `${pattern} (Pattern)`; + } + return pattern; +} + +/** + * Get a context-aware cost display string for a model cost entry. + * Chat models show "input / output", embeddings show a single value, + * images show per-image cost, videos show per-second cost. + */ +export function getCostDisplayForModelType(cost: CostDisplayFields): string { + switch (cost.modelType) { + case ModelType.Chat: + if (cost.inputCostPerMillionTokens !== undefined && cost.outputCostPerMillionTokens !== undefined) { + return `${formatCostPerMillionTokens(cost.inputCostPerMillionTokens)} / ${formatCostPerMillionTokens(cost.outputCostPerMillionTokens)}`; + } + return '-'; + case ModelType.Embedding: + if (cost.embeddingCostPerMillionTokens !== undefined) { + return formatCostPerMillionTokens(cost.embeddingCostPerMillionTokens); + } + return '-'; + case ModelType.Image: + return formatCostPerImage(cost.imageCostPerImage); + case ModelType.Video: + return formatCostPerSecond(cost.videoCostPerSecond); + default: + return '-'; + } +} + +/** + * Get the appropriate label describing the cost unit for a given model type. + */ +export function getCostTypeLabel(modelType: ModelType): string { + switch (modelType) { + case ModelType.Chat: + return 'Input / Output (per million tokens)'; + case ModelType.Embedding: + return 'Per million tokens'; + case ModelType.Image: + return 'Per image'; + case ModelType.Video: + return 'Per second'; + case ModelType.Audio: + return 'Per minute'; + default: + return 'Cost'; + } +} diff --git a/SDKs/Node/Common/dist/index.d.mts b/SDKs/Node/Common/dist/index.d.mts deleted file mode 100644 index c78554a3d..000000000 --- a/SDKs/Node/Common/dist/index.d.mts +++ /dev/null @@ -1,1310 +0,0 @@ -import * as signalR from '@microsoft/signalr'; - -/** - * Base response types shared across all Conduit SDK clients - */ -interface PaginatedResponse { - items: T[]; - totalCount: number; - pageNumber: number; - pageSize: number; - totalPages: number; -} -interface PagedResponse { - data: T[]; - totalCount: number; - page: number; - pageSize: number; - hasNextPage: boolean; - hasPreviousPage: boolean; -} -interface ErrorResponse { - error: string; - message?: string; - details?: Record; - statusCode?: number; -} -type SortDirection = 'asc' | 'desc'; -interface SortOptions { - field: string; - direction: SortDirection; -} -interface FilterOptions { - search?: string; - sortBy?: SortOptions; - pageNumber?: number; - pageSize?: number; -} -interface DateRange { - startDate: string; - endDate: string; -} -/** - * Common usage tracking interface - */ -interface Usage { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - is_batch?: boolean; - image_quality?: string; - cached_input_tokens?: number; - cached_write_tokens?: number; - search_units?: number; - inference_steps?: number; - image_count?: number; - video_duration_seconds?: number; - video_resolution?: string; - audio_duration_seconds?: number; -} -/** - * Performance metrics for API calls - */ -interface PerformanceMetrics { - provider_name: string; - provider_response_time_ms: number; - total_response_time_ms: number; - tokens_per_second?: number; -} - -/** - * Pagination and filtering types shared across Conduit SDK clients - */ -interface PaginationParams { - page?: number; - pageSize?: number; -} -interface SearchParams extends PaginationParams { - search?: string; - sortBy?: string; - sortDirection?: 'asc' | 'desc'; -} -interface TimeRangeParams { - startDate?: string; - endDate?: string; - timezone?: string; -} -interface BatchOperationParams { - batchSize?: number; - parallel?: boolean; - continueOnError?: boolean; -} - -/** - * Model capability definitions shared across Conduit SDK clients - */ -/** - * Core model capabilities supported by Conduit - */ -declare enum ModelCapability { - CHAT = "chat", - VISION = "vision", - IMAGE_GENERATION = "image-generation", - IMAGE_EDIT = "image-edit", - IMAGE_VARIATION = "image-variation", - AUDIO_TRANSCRIPTION = "audio-transcription", - TEXT_TO_SPEECH = "text-to-speech", - REALTIME_AUDIO = "realtime-audio", - EMBEDDINGS = "embeddings", - VIDEO_GENERATION = "video-generation" -} -/** - * Model capability metadata - */ -interface ModelCapabilityInfo { - id: ModelCapability; - displayName: string; - description?: string; - category: 'text' | 'vision' | 'audio' | 'video'; -} -/** - * Model capabilities definition for a specific model - */ -interface ModelCapabilities { - modelId: string; - capabilities: ModelCapability[]; - constraints?: ModelConstraints; -} -/** - * Model-specific constraints - */ -interface ModelConstraints { - maxTokens?: number; - maxImages?: number; - supportedImageSizes?: string[]; - supportedImageFormats?: string[]; - supportedAudioFormats?: string[]; - supportedVideoSizes?: string[]; - supportedLanguages?: string[]; - supportedVoices?: string[]; - maxDuration?: number; -} -/** - * Get user-friendly display name for a capability - */ -declare function getCapabilityDisplayName(capability: ModelCapability): string; -/** - * Get capability category - */ -declare function getCapabilityCategory(capability: ModelCapability): 'text' | 'vision' | 'audio' | 'video'; - -/** - * Common error types for Conduit SDK clients - * - * This module provides a unified error hierarchy for both Admin and Core SDKs, - * consolidating previously duplicated error classes. - */ -declare class ConduitError extends Error { - statusCode: number; - code: string; - context?: Record; - details?: unknown; - endpoint?: string; - method?: string; - type?: string; - param?: string; - constructor(message: string, statusCode?: number, code?: string, context?: Record); - toJSON(): { - name: string; - message: string; - statusCode: number; - code: string; - context: Record | undefined; - details: unknown; - endpoint: string | undefined; - method: string | undefined; - type: string | undefined; - param: string | undefined; - timestamp: string; - }; - toSerializable(): { - name: string; - message: string; - statusCode: number; - code: string; - context: Record | undefined; - details: unknown; - endpoint: string | undefined; - method: string | undefined; - type: string | undefined; - param: string | undefined; - timestamp: string; - isConduitError: boolean; - }; - static fromSerializable(data: unknown): ConduitError; -} -declare class AuthError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class AuthenticationError extends AuthError { -} -declare class AuthorizationError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class ValidationError extends ConduitError { - field?: string; - constructor(message?: string, context?: Record); -} -declare class NotFoundError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class ConflictError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class InsufficientBalanceError extends ConduitError { - balance?: number; - requiredAmount?: number; - constructor(message?: string, context?: Record); -} -declare class RateLimitError extends ConduitError { - retryAfter?: number; - constructor(message?: string, retryAfter?: number, context?: Record); -} -declare class ServerError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class NetworkError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class TimeoutError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class NotImplementedError extends ConduitError { - constructor(message: string, context?: Record); -} -declare class StreamError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare function isConduitError(error: unknown): error is ConduitError; -declare function isAuthError(error: unknown): error is AuthError; -declare function isAuthorizationError(error: unknown): error is AuthorizationError; -declare function isValidationError(error: unknown): error is ValidationError; -declare function isNotFoundError(error: unknown): error is NotFoundError; -declare function isConflictError(error: unknown): error is ConflictError; -declare function isInsufficientBalanceError(error: unknown): error is InsufficientBalanceError; -declare function isRateLimitError(error: unknown): error is RateLimitError; -declare function isNetworkError(error: unknown): error is NetworkError; -declare function isStreamError(error: unknown): error is StreamError; -declare function isTimeoutError(error: unknown): error is TimeoutError; -declare function isServerError(error: unknown): error is ConduitError; -declare function isSerializedConduitError(data: unknown): data is ReturnType; -declare function isHttpError(error: unknown): error is { - response: { - status: number; - data: unknown; - headers: Record; - }; - message: string; - request?: unknown; - code?: string; -}; -declare function isHttpNetworkError(error: unknown): error is { - request: unknown; - message: string; - code?: string; -}; -declare function isErrorLike(error: unknown): error is { - message: string; -}; -declare function serializeError(error: unknown): Record; -declare function deserializeError(data: unknown): Error; -declare function getErrorMessage(error: unknown): string; -declare function getErrorStatusCode(error: unknown): number; -/** - * Handle API errors and convert them to appropriate ConduitError types - * This function is primarily used by the Admin SDK - */ -declare function handleApiError(error: unknown, endpoint?: string, method?: string): never; -/** - * Create an error from an ErrorResponse format - * This function is primarily used by the Core SDK for legacy compatibility - */ -interface ErrorResponseFormat { - error: { - message: string; - type?: string; - code?: string; - param?: string; - }; -} -declare function createErrorFromResponse(response: ErrorResponseFormat, statusCode?: number): ConduitError; - -/** - * HTTP methods enum for type-safe API requests - */ -declare enum HttpMethod { - GET = "GET", - POST = "POST", - PUT = "PUT", - DELETE = "DELETE", - PATCH = "PATCH", - HEAD = "HEAD", - OPTIONS = "OPTIONS" -} -/** - * Type guard to check if a string is a valid HTTP method - */ -declare function isHttpMethod(method: string): method is HttpMethod; -/** - * Request options with proper typing - */ -interface RequestOptions { - headers?: Record; - signal?: AbortSignal; - timeout?: number; - body?: TRequest; - params?: Record; - responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; -} -/** - * Type-safe response interface - */ -interface ApiResponse { - data: T; - status: number; - statusText: string; - headers: Record; -} -/** - * Extended fetch options that include response type hints - * This provides a cleaner way to handle different response types - */ -interface ExtendedRequestInit extends RequestInit { - /** - * Hint for how to parse the response body - * This is not a standard fetch option but helps our client handle responses correctly - */ - responseType?: 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream'; - /** - * Custom timeout in milliseconds - */ - timeout?: number; - /** - * Request metadata for logging/debugging - */ - metadata?: { - /** Operation name for debugging */ - operation?: string; - /** Start time for performance tracking */ - startTime?: number; - /** Request ID for tracing */ - requestId?: string; - }; -} - -/** - * Response parser that handles different response types based on content-type and hints - */ -declare class ResponseParser { - /** - * Parses a fetch Response based on content type and response type hint - */ - static parse(response: Response, responseType?: ExtendedRequestInit['responseType']): Promise; - /** - * Creates a clean RequestInit object without custom properties - */ - static cleanRequestInit(init: ExtendedRequestInit): RequestInit; -} - -/** - * Common HTTP constants shared across all SDKs - */ -/** - * HTTP headers used across SDKs - */ -declare const HTTP_HEADERS: { - readonly CONTENT_TYPE: "Content-Type"; - readonly AUTHORIZATION: "Authorization"; - readonly X_API_KEY: "X-API-Key"; - readonly USER_AGENT: "User-Agent"; - readonly X_CORRELATION_ID: "X-Correlation-Id"; - readonly RETRY_AFTER: "Retry-After"; - readonly ACCEPT: "Accept"; - readonly CACHE_CONTROL: "Cache-Control"; -}; -type HttpHeader = typeof HTTP_HEADERS[keyof typeof HTTP_HEADERS]; -/** - * Content types - */ -declare const CONTENT_TYPES: { - readonly JSON: "application/json"; - readonly FORM_DATA: "multipart/form-data"; - readonly FORM_URLENCODED: "application/x-www-form-urlencoded"; - readonly TEXT_PLAIN: "text/plain"; - readonly TEXT_STREAM: "text/event-stream"; -}; -type ContentType = typeof CONTENT_TYPES[keyof typeof CONTENT_TYPES]; -/** - * HTTP status codes - */ -declare const HTTP_STATUS: { - readonly OK: 200; - readonly CREATED: 201; - readonly NO_CONTENT: 204; - readonly BAD_REQUEST: 400; - readonly UNAUTHORIZED: 401; - readonly FORBIDDEN: 403; - readonly NOT_FOUND: 404; - readonly CONFLICT: 409; - readonly TOO_MANY_REQUESTS: 429; - readonly RATE_LIMITED: 429; - readonly INTERNAL_SERVER_ERROR: 500; - readonly INTERNAL_ERROR: 500; - readonly BAD_GATEWAY: 502; - readonly SERVICE_UNAVAILABLE: 503; - readonly GATEWAY_TIMEOUT: 504; -}; -type HttpStatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS]; -/** - * Error codes for network errors - */ -declare const ERROR_CODES: { - readonly CONNECTION_ABORTED: "ECONNABORTED"; - readonly TIMEOUT: "ETIMEDOUT"; - readonly CONNECTION_RESET: "ECONNRESET"; - readonly NETWORK_UNREACHABLE: "ENETUNREACH"; - readonly CONNECTION_REFUSED: "ECONNREFUSED"; - readonly HOST_NOT_FOUND: "ENOTFOUND"; -}; -type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES]; -/** - * Default timeout values in milliseconds - */ -declare const TIMEOUTS: { - readonly DEFAULT_REQUEST: 60000; - readonly SHORT_REQUEST: 10000; - readonly LONG_REQUEST: 300000; - readonly STREAMING: 0; -}; -type TimeoutValue = typeof TIMEOUTS[keyof typeof TIMEOUTS]; -/** - * Retry configuration defaults - */ -declare const RETRY_CONFIG: { - readonly DEFAULT_MAX_RETRIES: 3; - readonly INITIAL_DELAY: 1000; - readonly MAX_DELAY: 30000; - readonly BACKOFF_FACTOR: 2; -}; -type RetryConfigValue = typeof RETRY_CONFIG[keyof typeof RETRY_CONFIG]; - -/** - * SignalR hub connection states - */ -declare enum HubConnectionState { - Disconnected = "Disconnected", - Connecting = "Connecting", - Connected = "Connected", - Disconnecting = "Disconnecting", - Reconnecting = "Reconnecting" -} -/** - * SignalR logging levels - */ -declare enum SignalRLogLevel { - Trace = 0, - Debug = 1, - Information = 2, - Warning = 3, - Error = 4, - Critical = 5, - None = 6 -} -/** - * HTTP transport types for SignalR - */ -declare enum HttpTransportType { - None = 0, - WebSockets = 1, - ServerSentEvents = 2, - LongPolling = 4 -} -/** - * Default transport configuration - */ -declare const DefaultTransports: number; -/** - * SignalR protocol types - */ -declare enum SignalRProtocolType { - /** - * JSON protocol (default) - */ - Json = "json", - /** - * MessagePack binary protocol with compression - */ - MessagePack = "messagepack" -} -/** - * Base SignalR connection options - */ -interface SignalRConnectionOptions { - /** - * Logging level - */ - logLevel?: SignalRLogLevel; - /** - * Transport types to use - */ - transport?: HttpTransportType; - /** - * Headers to include with requests - */ - headers?: Record; - /** - * Access token factory for authentication - */ - accessTokenFactory?: () => string | Promise; - /** - * Close timeout in milliseconds - */ - closeTimeout?: number; - /** - * Reconnection delay intervals in milliseconds - */ - reconnectionDelay?: number[]; - /** - * Server timeout in milliseconds - */ - serverTimeout?: number; - /** - * Keep-alive interval in milliseconds - */ - keepAliveInterval?: number; - /** - * Protocol to use for SignalR communication - * @default SignalRProtocolType.Json - */ - protocol?: SignalRProtocolType; -} -/** - * Authentication configuration for SignalR connections - */ -interface SignalRAuthConfig { - /** - * Authentication token or key - */ - authToken: string; - /** - * Authentication type (e.g., 'master', 'virtual') - */ - authType: 'master' | 'virtual'; - /** - * Additional headers for authentication - */ - additionalHeaders?: Record; -} -/** - * SignalR hub method argument types for type safety - */ -type SignalRPrimitive = string | number | boolean | null | undefined; -type SignalRValue = SignalRPrimitive | SignalRArgs | SignalRPrimitive[]; -interface SignalRArgs { - [key: string]: SignalRValue; -} - -/** - * Base configuration for SignalR connections - */ -interface BaseSignalRConfig { - /** - * Base URL for the SignalR hub - */ - baseUrl: string; - /** - * Authentication configuration - */ - auth: SignalRAuthConfig; - /** - * Connection options - */ - options?: SignalRConnectionOptions; - /** - * User agent string - */ - userAgent?: string; -} -/** - * Base class for SignalR hub connections with automatic reconnection and error handling. - * This abstract class provides common functionality for both Admin and Core SDKs. - */ -declare abstract class BaseSignalRConnection { - protected connection?: signalR.HubConnection; - protected readonly config: BaseSignalRConfig; - protected connectionReadyPromise: Promise; - private connectionReadyResolve?; - private connectionReadyReject?; - private disposed; - /** - * Gets the hub path for this connection type. - */ - protected abstract get hubPath(): string; - constructor(config: BaseSignalRConfig); - /** - * Gets whether the connection is established and ready for use. - */ - get isConnected(): boolean; - /** - * Gets the current connection state. - */ - get state(): HubConnectionState; - /** - * Event handlers - */ - onConnected?: () => Promise; - onDisconnected?: (error?: Error) => Promise; - onReconnecting?: (error?: Error) => Promise; - onReconnected?: (connectionId?: string) => Promise; - /** - * Establishes the SignalR connection. - */ - protected getConnection(): Promise; - /** - * Configures hub-specific event handlers. Override in derived classes. - */ - protected abstract configureHubHandlers(connection: signalR.HubConnection): void; - /** - * Maps transport type enum to SignalR transport. - */ - protected mapTransportType(transport: HttpTransportType): signalR.HttpTransportType; - /** - * Maps log level enum to SignalR log level. - */ - protected mapLogLevel(level: SignalRLogLevel): signalR.LogLevel; - /** - * Builds headers for the connection based on configuration. - */ - private buildHeaders; - /** - * Waits for the connection to be ready. - */ - waitForReady(): Promise; - /** - * Invokes a method on the hub with proper error handling. - */ - protected invoke(methodName: string, ...args: unknown[]): Promise; - /** - * Sends a message to the hub without expecting a response. - */ - protected send(methodName: string, ...args: unknown[]): Promise; - /** - * Disconnects the SignalR connection. - */ - disconnect(): Promise; - /** - * Disposes of the connection and cleans up resources. - */ - dispose(): Promise; -} - -/** - * Logger interface for client logging - */ -interface Logger { - debug(message: string, ...args: unknown[]): void; - info(message: string, ...args: unknown[]): void; - warn(message: string, ...args: unknown[]): void; - error(message: string, ...args: unknown[]): void; -} -/** - * Cache provider interface for client-side caching - */ -interface CacheProvider { - get(key: string): Promise; - set(key: string, value: T, ttl?: number): Promise; - delete(key: string): Promise; - clear(): Promise; -} -/** - * Base retry configuration interface - * - * Note: The Admin and Core SDKs have different retry strategies: - * - Admin SDK uses simple fixed delay retry - * - Core SDK uses exponential backoff - * - * This base interface supports both patterns. - */ -interface RetryConfig { - /** - * Maximum number of retry attempts - */ - maxRetries: number; - /** - * For Admin SDK: Fixed delay between retries in milliseconds - * For Core SDK: Initial delay for exponential backoff - */ - retryDelay?: number; - /** - * For Core SDK: Initial delay for exponential backoff - */ - initialDelay?: number; - /** - * For Core SDK: Maximum delay between retries - */ - maxDelay?: number; - /** - * For Core SDK: Backoff multiplication factor - */ - factor?: number; - /** - * Custom retry condition function - */ - retryCondition?: (error: unknown) => boolean; -} -/** - * HTTP error class - */ -declare class HttpError extends Error { - code?: string; - response?: { - status: number; - data: unknown; - headers: Record; - }; - request?: unknown; - config?: { - url?: string; - method?: string; - _retry?: number; - }; - constructor(message: string, code?: string); -} -/** - * Request configuration information - */ -interface RequestConfigInfo { - method: string; - url: string; - headers: Record; - data?: unknown; - params?: Record; -} -/** - * Response information - */ -interface ResponseInfo { - status: number; - statusText: string; - headers: Record; - data: unknown; - config: RequestConfigInfo; -} -/** - * Base client lifecycle callbacks - */ -interface ClientLifecycleCallbacks { - /** - * Callback invoked on any error - */ - onError?: (error: Error) => void; - /** - * Callback invoked before each request - */ - onRequest?: (config: RequestConfigInfo) => void | Promise; - /** - * Callback invoked after each response - */ - onResponse?: (response: ResponseInfo) => void | Promise; -} -/** - * Base client configuration options - */ -interface BaseClientOptions extends ClientLifecycleCallbacks { - /** - * Request timeout in milliseconds - */ - timeout?: number; - /** - * Retry configuration - */ - retries?: number | RetryConfig; - /** - * Logger instance for client logging - */ - logger?: Logger; - /** - * Cache provider for response caching - */ - cache?: CacheProvider; - /** - * Custom headers to include with all requests - */ - headers?: Record; - /** - * Custom retry delays in milliseconds (overrides retry config) - * @default [1000, 2000, 4000, 8000, 16000] - */ - retryDelay?: number[]; - /** - * Custom function to validate response status - */ - validateStatus?: (status: number) => boolean; - /** - * Enable debug mode - */ - debug?: boolean; -} - -/** - * SignalR client configuration - */ -interface SignalRConfig { - /** - * Whether SignalR is enabled - * @default true - */ - enabled?: boolean; - /** - * Whether to automatically connect on client initialization - * @default true - */ - autoConnect?: boolean; - /** - * Reconnection delays in milliseconds (exponential backoff) - * @default [0, 2000, 10000, 30000] - */ - reconnectDelay?: number[]; - /** - * SignalR logging level - * @default SignalRLogLevel.Information - */ - logLevel?: SignalRLogLevel; - /** - * HTTP transport type - * @default HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling - */ - transport?: HttpTransportType; - /** - * Custom headers for SignalR connections - */ - headers?: Record; - /** - * Connection timeout in milliseconds - * @default 30000 - */ - connectionTimeout?: number; -} - -/** - * Retry strategy types and utilities for SDK HTTP clients - * Supports both fixed delay (Admin SDK) and exponential backoff (Gateway SDK) patterns - */ -/** - * Type of retry strategy to use - */ -declare enum RetryStrategyType { - /** Fixed delay between retries (Admin SDK pattern) */ - FIXED_DELAY = "fixed_delay", - /** Exponential backoff with optional jitter (Gateway SDK pattern) */ - EXPONENTIAL_BACKOFF = "exponential_backoff", - /** Custom array of delays */ - CUSTOM_DELAYS = "custom_delays" -} -/** - * Fixed delay retry configuration - * Used by Admin SDK for simple retry patterns - */ -interface FixedDelayConfig { - type: RetryStrategyType.FIXED_DELAY; - /** Maximum number of retry attempts */ - maxRetries: number; - /** Delay between retries in milliseconds */ - delayMs: number; - /** Optional custom condition to determine if error is retryable */ - retryCondition?: (error: unknown) => boolean; -} -/** - * Exponential backoff retry configuration - * Used by Gateway SDK for sophisticated retry patterns - */ -interface ExponentialBackoffConfig { - type: RetryStrategyType.EXPONENTIAL_BACKOFF; - /** Maximum number of retry attempts */ - maxRetries: number; - /** Initial delay in milliseconds */ - initialDelayMs: number; - /** Maximum delay cap in milliseconds */ - maxDelayMs: number; - /** Multiplication factor for each retry */ - factor: number; - /** Whether to add random jitter to prevent thundering herd */ - jitter?: boolean; - /** Optional custom condition to determine if error is retryable */ - retryCondition?: (error: unknown) => boolean; -} -/** - * Custom delays retry configuration - * Allows specifying exact delay for each retry attempt - */ -interface CustomDelaysConfig { - type: RetryStrategyType.CUSTOM_DELAYS; - /** Array of delays in milliseconds for each retry attempt */ - delays: number[]; - /** Optional custom condition to determine if error is retryable */ - retryCondition?: (error: unknown) => boolean; -} -/** - * Union type for all retry strategy configurations - */ -type RetryStrategy = FixedDelayConfig | ExponentialBackoffConfig | CustomDelaysConfig; -/** - * Calculate the delay for a retry attempt based on the strategy - * @param strategy - The retry strategy configuration - * @param attempt - The current attempt number (1-based) - * @returns Delay in milliseconds before the next retry - */ -declare function calculateRetryDelay(strategy: RetryStrategy, attempt: number): number; -/** - * Get the maximum number of retries for a strategy - * @param strategy - The retry strategy configuration - * @returns Maximum number of retry attempts - */ -declare function getMaxRetries(strategy: RetryStrategy): number; -/** - * Check if an error should be retried based on the strategy's condition - * @param strategy - The retry strategy configuration - * @param error - The error to check - * @returns Whether the error should trigger a retry - */ -declare function shouldRetryWithStrategy(strategy: RetryStrategy, error: unknown): boolean; -/** - * Default retry strategies for each SDK type - */ -declare const DEFAULT_RETRY_STRATEGIES: { - /** Gateway SDK default: exponential backoff with jitter */ - gateway: ExponentialBackoffConfig; - /** Admin SDK default: fixed delay */ - admin: FixedDelayConfig; -}; - -/** - * Base client configuration types for SDK HTTP clients - */ - -/** - * Base configuration shared by all API clients - */ -interface BaseApiClientConfig extends ClientLifecycleCallbacks { - /** Base URL for API requests (trailing slash will be removed) */ - baseUrl: string; - /** Request timeout in milliseconds (default: 60000) */ - timeout?: number; - /** Default headers included with all requests */ - defaultHeaders?: Record; - /** Retry strategy configuration */ - retryStrategy?: RetryStrategy; - /** Enable debug logging (default: false) */ - debug?: boolean; - /** Optional logger for structured logging */ - logger?: Logger; - /** Optional cache provider for response caching */ - cache?: CacheProvider; -} -/** - * Configuration for clients that support caching - * @deprecated Use BaseApiClientConfig with optional cache property - */ -interface CacheableClientConfig extends BaseApiClientConfig { - /** Cache provider for response caching */ - cache?: CacheProvider; -} -/** - * Configuration for clients that support logging - * @deprecated Use BaseApiClientConfig with optional logger property - */ -interface LoggableClientConfig extends BaseApiClientConfig { - /** Logger instance for structured logging */ - logger?: Logger; -} -/** - * Full-featured client configuration with all optional features - * Used by Admin SDK which supports both caching and logging - */ -interface FullFeaturedClientConfig extends BaseApiClientConfig { - /** Cache provider for response caching */ - cache?: CacheProvider; - /** Logger instance for structured logging */ - logger?: Logger; -} - -/** - * Abstract base API client providing common HTTP functionality - * - * SDK-specific clients extend this class and implement: - * - getAuthHeaders(): Returns authentication headers - * - getDefaultRetryStrategy(): Returns default retry strategy - * - * Template methods that can be overridden: - * - handleErrorResponse(): SDK-specific error parsing - * - shouldRetry(): SDK-specific retry logic - * - getRetryDelay(): SDK-specific delay calculation - */ - -/** - * Request options for individual requests - */ -interface BaseRequestOptions { - /** Additional headers for this request */ - headers?: Record; - /** AbortSignal for request cancellation */ - signal?: AbortSignal; - /** Request timeout in milliseconds (overrides client default) */ - timeout?: number; - /** Expected response type */ - responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; -} -/** - * Abstract base API client providing common HTTP functionality - * - * Both Gateway SDK and Admin SDK extend this class. - */ -declare abstract class BaseApiClient { - /** Base URL for all requests (without trailing slash) */ - protected readonly baseUrl: string; - /** Default timeout in milliseconds */ - protected readonly timeout: number; - /** Default headers included with all requests */ - protected readonly defaultHeaders: Record; - /** Retry strategy configuration */ - protected readonly retryStrategy: RetryStrategy; - /** Enable debug logging */ - protected readonly debug: boolean; - protected readonly onError?: (error: Error) => void; - protected readonly onRequest?: (config: RequestConfigInfo) => void | Promise; - protected readonly onResponse?: (response: ResponseInfo) => void | Promise; - protected readonly logger?: Logger; - protected readonly cache?: CacheProvider; - constructor(config: BaseApiClientConfig); - /** - * Returns authentication headers for this SDK - * - * Gateway SDK returns: { Authorization: 'Bearer ...' } - * Admin SDK returns: { 'X-Master-Key': '...' } - */ - protected abstract getAuthHeaders(): Record; - /** - * Returns default retry strategy for this SDK - * - * Gateway SDK uses exponential backoff with jitter - * Admin SDK uses fixed delay - */ - protected abstract getDefaultRetryStrategy(): RetryStrategy; - /** - * Transform error response into appropriate error type - * Subclasses can override for SDK-specific error handling - * - * @param response - The failed Response object - * @returns An Error to throw - */ - protected handleErrorResponse(response: Response): Promise; - /** - * Determine if an error should be retried - * Subclasses can override for SDK-specific retry logic - * - * @param error - The error that occurred - * @param attempt - Current attempt number (1-based) - * @returns Whether to retry the request - */ - protected shouldRetry(error: unknown, attempt: number): boolean; - /** - * Calculate delay for a retry attempt - * Subclasses can override for special cases (e.g., retry-after headers) - * - * @param error - The error that triggered the retry - * @param attempt - Current attempt number (1-based) - * @returns Delay in milliseconds before next retry - */ - protected getRetryDelay(_error: unknown, attempt: number): number; - /** - * Main request method with retry logic - */ - protected request(url: string, options?: BaseRequestOptions & { - method?: HttpMethod; - body?: TRequest; - }): Promise; - /** - * Type-safe GET request - */ - protected get(url: string, options?: BaseRequestOptions): Promise; - /** - * Type-safe POST request - */ - protected post(url: string, data?: TRequest, options?: BaseRequestOptions): Promise; - /** - * Type-safe PUT request - */ - protected put(url: string, data?: TRequest, options?: BaseRequestOptions): Promise; - /** - * Type-safe PATCH request - */ - protected patch(url: string, data?: TRequest, options?: BaseRequestOptions): Promise; - /** - * Type-safe DELETE request - */ - protected delete(url: string, options?: BaseRequestOptions): Promise; - /** - * Execute request with retry logic - */ - private executeWithRetry; - /** - * Build full URL from path - */ - private buildUrl; - /** - * Build headers including auth, defaults, and additional headers - */ - private buildHeaders; - /** - * Log a message using the configured logger or console in debug mode - */ - protected log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: unknown[]): void; - /** - * Sleep for a specified duration - */ - private sleep; - /** - * Get a value from cache - * Returns null if cache is not configured or key is not found - */ - protected getFromCache(key: string): Promise; - /** - * Set a value in cache - * No-op if cache is not configured - */ - protected setCache(key: string, value: unknown, ttl?: number): Promise; - /** - * Execute a function with caching - * Returns cached value if available, otherwise executes function and caches result - */ - protected withCache(cacheKey: string, fn: () => Promise, ttl?: number): Promise; - /** - * Generate a cache key from resource and identifiers - */ - protected getCacheKey(resource: string, ...identifiers: (string | number | Record | undefined)[]): string; -} - -/** - * Circuit breaker types and interfaces - * - * Provides types for implementing the circuit breaker pattern to prevent - * cascading failures and protect against sustained service degradation. - */ -/** - * Circuit breaker states following the standard pattern - */ -declare enum CircuitState { - /** Normal operation - requests pass through, failures tracked */ - CLOSED = "closed", - /** Circuit tripped - requests are blocked/rejected immediately */ - OPEN = "open", - /** Testing recovery - limited requests allowed to test if service recovered */ - HALF_OPEN = "half_open" -} -/** - * Configuration options for the circuit breaker - */ -interface CircuitBreakerConfig { - /** Number of consecutive failures to trip the circuit (default: 3) */ - failureThreshold?: number; - /** Time window in milliseconds for counting failures (default: 60000) */ - failureWindowMs?: number; - /** Time in milliseconds to wait before transitioning from OPEN to HALF_OPEN (default: 30000) */ - resetTimeoutMs?: number; - /** Number of successful requests in HALF_OPEN to close circuit (default: 1) */ - successThreshold?: number; - /** Enable debug logging (default: false) */ - enableLogging?: boolean; - /** Custom function to determine if an error should count as a failure */ - shouldCountAsFailure?: (error: unknown) => boolean; -} -/** - * Statistics about the circuit breaker state - */ -interface CircuitBreakerStats { - /** Current state of the circuit */ - state: CircuitState; - /** Number of consecutive failures in current window */ - consecutiveFailures: number; - /** Total failures since last reset */ - totalFailures: number; - /** Total successes since last reset */ - totalSuccesses: number; - /** Timestamp when circuit was opened (null if closed) */ - circuitOpenedAt: number | null; - /** Time remaining until HALF_OPEN transition in ms (null if not OPEN) */ - timeUntilHalfOpen: number | null; - /** Timestamp of last failure */ - lastFailureAt: number | null; - /** Timestamp of last success */ - lastSuccessAt: number | null; - /** Number of requests rejected while OPEN */ - rejectedRequests: number; -} -/** - * Callbacks for circuit breaker state changes - */ -interface CircuitBreakerCallbacks { - /** Called when circuit transitions to OPEN state */ - onOpen?: (stats: CircuitBreakerStats, error: unknown) => void; - /** Called when circuit transitions to HALF_OPEN state */ - onHalfOpen?: (stats: CircuitBreakerStats) => void; - /** Called when circuit transitions to CLOSED state */ - onClose?: (stats: CircuitBreakerStats) => void; - /** Called when a request is rejected due to OPEN circuit */ - onRejected?: (stats: CircuitBreakerStats) => void; - /** Called on any state change */ - onStateChange?: (oldState: CircuitState, newState: CircuitState, stats: CircuitBreakerStats) => void; -} - -/** - * Circuit breaker error types - */ - -/** - * Error thrown when circuit breaker is open and request is rejected - */ -declare class CircuitBreakerOpenError extends ConduitError { - /** Current circuit breaker state */ - readonly circuitState: CircuitState; - /** Time until circuit transitions to HALF_OPEN (milliseconds) */ - readonly timeUntilHalfOpen: number | null; - /** Circuit breaker statistics at time of rejection */ - readonly stats: CircuitBreakerStats; - constructor(message: string, stats: CircuitBreakerStats, timeUntilHalfOpen: number | null); -} -/** - * Type guard for CircuitBreakerOpenError - */ -declare function isCircuitBreakerOpenError(error: unknown): error is CircuitBreakerOpenError; - -/** - * Circuit breaker implementation for preventing cascading failures - * - * Implements the circuit breaker pattern with three states: - * - CLOSED: Normal operation, counting failures - * - OPEN: Circuit tripped, rejecting requests - * - HALF_OPEN: Testing recovery with limited requests - */ - -/** - * Circuit breaker implementation for preventing cascading failures - * - * State machine: - * - CLOSED: Normal operation, counting failures - * - OPEN: Circuit tripped, rejecting requests - * - HALF_OPEN: Testing recovery with limited requests - */ -declare class CircuitBreaker { - private readonly config; - private readonly callbacks; - private state; - private failures; - private halfOpenSuccesses; - private totalFailures; - private totalSuccesses; - private rejectedRequests; - private circuitOpenedAt; - private lastFailureAt; - private lastSuccessAt; - constructor(config?: CircuitBreakerConfig, callbacks?: CircuitBreakerCallbacks); - /** - * Get current state of the circuit - * Automatically transitions OPEN -> HALF_OPEN after timeout - */ - getState(): CircuitState; - /** - * Get circuit breaker statistics - */ - getStats(): CircuitBreakerStats; - /** - * Check if a request can proceed - * Returns true if circuit is CLOSED or HALF_OPEN - */ - canExecute(): boolean; - /** - * Check if request should proceed, throwing if circuit is open - * @throws CircuitBreakerOpenError if circuit is OPEN - */ - checkOpen(): void; - /** - * Record a successful request - */ - recordSuccess(): void; - /** - * Record a failed request - */ - recordFailure(error: unknown): void; - /** - * Manually reset the circuit to CLOSED state - * Use with caution - typically for testing or admin override - */ - reset(): void; - private transitionTo; - private pruneOldFailures; - private getConsecutiveFailuresInWindow; - private calculateTimeUntilHalfOpen; - private log; -} - -export { type ApiResponse, AuthError, AuthenticationError, AuthorizationError, BaseApiClient, type BaseApiClientConfig, type BaseClientOptions, type BaseRequestOptions, type BaseSignalRConfig, BaseSignalRConnection, type BatchOperationParams, CONTENT_TYPES, type CacheProvider, type CacheableClientConfig, CircuitBreaker, type CircuitBreakerCallbacks, type CircuitBreakerConfig, CircuitBreakerOpenError, type CircuitBreakerStats, CircuitState, type ClientLifecycleCallbacks, ConduitError, ConflictError, type ContentType, type CustomDelaysConfig, DEFAULT_RETRY_STRATEGIES, type DateRange, DefaultTransports, ERROR_CODES, type ErrorCode, type ErrorResponse, type ErrorResponseFormat, type ExponentialBackoffConfig, type ExtendedRequestInit, type FilterOptions, type FixedDelayConfig, type FullFeaturedClientConfig, HTTP_HEADERS, HTTP_STATUS, HttpError, type HttpHeader, HttpMethod, type HttpStatusCode, HttpTransportType, HubConnectionState, InsufficientBalanceError, type LoggableClientConfig, type Logger, type ModelCapabilities, ModelCapability, type ModelCapabilityInfo, type ModelConstraints, NetworkError, NotFoundError, NotImplementedError, type PagedResponse, type PaginatedResponse, type PaginationParams, type PerformanceMetrics, RETRY_CONFIG, RateLimitError, type RequestConfigInfo, type RequestOptions, type ResponseInfo, ResponseParser, type RetryConfig, type RetryConfigValue, type RetryStrategy, RetryStrategyType, type SearchParams, ServerError, type SignalRArgs, type SignalRAuthConfig, type SignalRConfig, type SignalRConnectionOptions, SignalRLogLevel, SignalRProtocolType, type SignalRValue, type SortDirection, type SortOptions, StreamError, TIMEOUTS, type TimeRangeParams, TimeoutError, type TimeoutValue, type Usage, ValidationError, calculateRetryDelay, createErrorFromResponse, deserializeError, getCapabilityCategory, getCapabilityDisplayName, getErrorMessage, getErrorStatusCode, getMaxRetries, handleApiError, isAuthError, isAuthorizationError, isCircuitBreakerOpenError, isConduitError, isConflictError, isErrorLike, isHttpError, isHttpMethod, isHttpNetworkError, isInsufficientBalanceError, isNetworkError, isNotFoundError, isRateLimitError, isSerializedConduitError, isServerError, isStreamError, isTimeoutError, isValidationError, serializeError, shouldRetryWithStrategy }; diff --git a/SDKs/Node/Common/dist/index.d.ts b/SDKs/Node/Common/dist/index.d.ts deleted file mode 100644 index c78554a3d..000000000 --- a/SDKs/Node/Common/dist/index.d.ts +++ /dev/null @@ -1,1310 +0,0 @@ -import * as signalR from '@microsoft/signalr'; - -/** - * Base response types shared across all Conduit SDK clients - */ -interface PaginatedResponse { - items: T[]; - totalCount: number; - pageNumber: number; - pageSize: number; - totalPages: number; -} -interface PagedResponse { - data: T[]; - totalCount: number; - page: number; - pageSize: number; - hasNextPage: boolean; - hasPreviousPage: boolean; -} -interface ErrorResponse { - error: string; - message?: string; - details?: Record; - statusCode?: number; -} -type SortDirection = 'asc' | 'desc'; -interface SortOptions { - field: string; - direction: SortDirection; -} -interface FilterOptions { - search?: string; - sortBy?: SortOptions; - pageNumber?: number; - pageSize?: number; -} -interface DateRange { - startDate: string; - endDate: string; -} -/** - * Common usage tracking interface - */ -interface Usage { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - is_batch?: boolean; - image_quality?: string; - cached_input_tokens?: number; - cached_write_tokens?: number; - search_units?: number; - inference_steps?: number; - image_count?: number; - video_duration_seconds?: number; - video_resolution?: string; - audio_duration_seconds?: number; -} -/** - * Performance metrics for API calls - */ -interface PerformanceMetrics { - provider_name: string; - provider_response_time_ms: number; - total_response_time_ms: number; - tokens_per_second?: number; -} - -/** - * Pagination and filtering types shared across Conduit SDK clients - */ -interface PaginationParams { - page?: number; - pageSize?: number; -} -interface SearchParams extends PaginationParams { - search?: string; - sortBy?: string; - sortDirection?: 'asc' | 'desc'; -} -interface TimeRangeParams { - startDate?: string; - endDate?: string; - timezone?: string; -} -interface BatchOperationParams { - batchSize?: number; - parallel?: boolean; - continueOnError?: boolean; -} - -/** - * Model capability definitions shared across Conduit SDK clients - */ -/** - * Core model capabilities supported by Conduit - */ -declare enum ModelCapability { - CHAT = "chat", - VISION = "vision", - IMAGE_GENERATION = "image-generation", - IMAGE_EDIT = "image-edit", - IMAGE_VARIATION = "image-variation", - AUDIO_TRANSCRIPTION = "audio-transcription", - TEXT_TO_SPEECH = "text-to-speech", - REALTIME_AUDIO = "realtime-audio", - EMBEDDINGS = "embeddings", - VIDEO_GENERATION = "video-generation" -} -/** - * Model capability metadata - */ -interface ModelCapabilityInfo { - id: ModelCapability; - displayName: string; - description?: string; - category: 'text' | 'vision' | 'audio' | 'video'; -} -/** - * Model capabilities definition for a specific model - */ -interface ModelCapabilities { - modelId: string; - capabilities: ModelCapability[]; - constraints?: ModelConstraints; -} -/** - * Model-specific constraints - */ -interface ModelConstraints { - maxTokens?: number; - maxImages?: number; - supportedImageSizes?: string[]; - supportedImageFormats?: string[]; - supportedAudioFormats?: string[]; - supportedVideoSizes?: string[]; - supportedLanguages?: string[]; - supportedVoices?: string[]; - maxDuration?: number; -} -/** - * Get user-friendly display name for a capability - */ -declare function getCapabilityDisplayName(capability: ModelCapability): string; -/** - * Get capability category - */ -declare function getCapabilityCategory(capability: ModelCapability): 'text' | 'vision' | 'audio' | 'video'; - -/** - * Common error types for Conduit SDK clients - * - * This module provides a unified error hierarchy for both Admin and Core SDKs, - * consolidating previously duplicated error classes. - */ -declare class ConduitError extends Error { - statusCode: number; - code: string; - context?: Record; - details?: unknown; - endpoint?: string; - method?: string; - type?: string; - param?: string; - constructor(message: string, statusCode?: number, code?: string, context?: Record); - toJSON(): { - name: string; - message: string; - statusCode: number; - code: string; - context: Record | undefined; - details: unknown; - endpoint: string | undefined; - method: string | undefined; - type: string | undefined; - param: string | undefined; - timestamp: string; - }; - toSerializable(): { - name: string; - message: string; - statusCode: number; - code: string; - context: Record | undefined; - details: unknown; - endpoint: string | undefined; - method: string | undefined; - type: string | undefined; - param: string | undefined; - timestamp: string; - isConduitError: boolean; - }; - static fromSerializable(data: unknown): ConduitError; -} -declare class AuthError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class AuthenticationError extends AuthError { -} -declare class AuthorizationError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class ValidationError extends ConduitError { - field?: string; - constructor(message?: string, context?: Record); -} -declare class NotFoundError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class ConflictError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class InsufficientBalanceError extends ConduitError { - balance?: number; - requiredAmount?: number; - constructor(message?: string, context?: Record); -} -declare class RateLimitError extends ConduitError { - retryAfter?: number; - constructor(message?: string, retryAfter?: number, context?: Record); -} -declare class ServerError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class NetworkError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class TimeoutError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare class NotImplementedError extends ConduitError { - constructor(message: string, context?: Record); -} -declare class StreamError extends ConduitError { - constructor(message?: string, context?: Record); -} -declare function isConduitError(error: unknown): error is ConduitError; -declare function isAuthError(error: unknown): error is AuthError; -declare function isAuthorizationError(error: unknown): error is AuthorizationError; -declare function isValidationError(error: unknown): error is ValidationError; -declare function isNotFoundError(error: unknown): error is NotFoundError; -declare function isConflictError(error: unknown): error is ConflictError; -declare function isInsufficientBalanceError(error: unknown): error is InsufficientBalanceError; -declare function isRateLimitError(error: unknown): error is RateLimitError; -declare function isNetworkError(error: unknown): error is NetworkError; -declare function isStreamError(error: unknown): error is StreamError; -declare function isTimeoutError(error: unknown): error is TimeoutError; -declare function isServerError(error: unknown): error is ConduitError; -declare function isSerializedConduitError(data: unknown): data is ReturnType; -declare function isHttpError(error: unknown): error is { - response: { - status: number; - data: unknown; - headers: Record; - }; - message: string; - request?: unknown; - code?: string; -}; -declare function isHttpNetworkError(error: unknown): error is { - request: unknown; - message: string; - code?: string; -}; -declare function isErrorLike(error: unknown): error is { - message: string; -}; -declare function serializeError(error: unknown): Record; -declare function deserializeError(data: unknown): Error; -declare function getErrorMessage(error: unknown): string; -declare function getErrorStatusCode(error: unknown): number; -/** - * Handle API errors and convert them to appropriate ConduitError types - * This function is primarily used by the Admin SDK - */ -declare function handleApiError(error: unknown, endpoint?: string, method?: string): never; -/** - * Create an error from an ErrorResponse format - * This function is primarily used by the Core SDK for legacy compatibility - */ -interface ErrorResponseFormat { - error: { - message: string; - type?: string; - code?: string; - param?: string; - }; -} -declare function createErrorFromResponse(response: ErrorResponseFormat, statusCode?: number): ConduitError; - -/** - * HTTP methods enum for type-safe API requests - */ -declare enum HttpMethod { - GET = "GET", - POST = "POST", - PUT = "PUT", - DELETE = "DELETE", - PATCH = "PATCH", - HEAD = "HEAD", - OPTIONS = "OPTIONS" -} -/** - * Type guard to check if a string is a valid HTTP method - */ -declare function isHttpMethod(method: string): method is HttpMethod; -/** - * Request options with proper typing - */ -interface RequestOptions { - headers?: Record; - signal?: AbortSignal; - timeout?: number; - body?: TRequest; - params?: Record; - responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; -} -/** - * Type-safe response interface - */ -interface ApiResponse { - data: T; - status: number; - statusText: string; - headers: Record; -} -/** - * Extended fetch options that include response type hints - * This provides a cleaner way to handle different response types - */ -interface ExtendedRequestInit extends RequestInit { - /** - * Hint for how to parse the response body - * This is not a standard fetch option but helps our client handle responses correctly - */ - responseType?: 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream'; - /** - * Custom timeout in milliseconds - */ - timeout?: number; - /** - * Request metadata for logging/debugging - */ - metadata?: { - /** Operation name for debugging */ - operation?: string; - /** Start time for performance tracking */ - startTime?: number; - /** Request ID for tracing */ - requestId?: string; - }; -} - -/** - * Response parser that handles different response types based on content-type and hints - */ -declare class ResponseParser { - /** - * Parses a fetch Response based on content type and response type hint - */ - static parse(response: Response, responseType?: ExtendedRequestInit['responseType']): Promise; - /** - * Creates a clean RequestInit object without custom properties - */ - static cleanRequestInit(init: ExtendedRequestInit): RequestInit; -} - -/** - * Common HTTP constants shared across all SDKs - */ -/** - * HTTP headers used across SDKs - */ -declare const HTTP_HEADERS: { - readonly CONTENT_TYPE: "Content-Type"; - readonly AUTHORIZATION: "Authorization"; - readonly X_API_KEY: "X-API-Key"; - readonly USER_AGENT: "User-Agent"; - readonly X_CORRELATION_ID: "X-Correlation-Id"; - readonly RETRY_AFTER: "Retry-After"; - readonly ACCEPT: "Accept"; - readonly CACHE_CONTROL: "Cache-Control"; -}; -type HttpHeader = typeof HTTP_HEADERS[keyof typeof HTTP_HEADERS]; -/** - * Content types - */ -declare const CONTENT_TYPES: { - readonly JSON: "application/json"; - readonly FORM_DATA: "multipart/form-data"; - readonly FORM_URLENCODED: "application/x-www-form-urlencoded"; - readonly TEXT_PLAIN: "text/plain"; - readonly TEXT_STREAM: "text/event-stream"; -}; -type ContentType = typeof CONTENT_TYPES[keyof typeof CONTENT_TYPES]; -/** - * HTTP status codes - */ -declare const HTTP_STATUS: { - readonly OK: 200; - readonly CREATED: 201; - readonly NO_CONTENT: 204; - readonly BAD_REQUEST: 400; - readonly UNAUTHORIZED: 401; - readonly FORBIDDEN: 403; - readonly NOT_FOUND: 404; - readonly CONFLICT: 409; - readonly TOO_MANY_REQUESTS: 429; - readonly RATE_LIMITED: 429; - readonly INTERNAL_SERVER_ERROR: 500; - readonly INTERNAL_ERROR: 500; - readonly BAD_GATEWAY: 502; - readonly SERVICE_UNAVAILABLE: 503; - readonly GATEWAY_TIMEOUT: 504; -}; -type HttpStatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS]; -/** - * Error codes for network errors - */ -declare const ERROR_CODES: { - readonly CONNECTION_ABORTED: "ECONNABORTED"; - readonly TIMEOUT: "ETIMEDOUT"; - readonly CONNECTION_RESET: "ECONNRESET"; - readonly NETWORK_UNREACHABLE: "ENETUNREACH"; - readonly CONNECTION_REFUSED: "ECONNREFUSED"; - readonly HOST_NOT_FOUND: "ENOTFOUND"; -}; -type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES]; -/** - * Default timeout values in milliseconds - */ -declare const TIMEOUTS: { - readonly DEFAULT_REQUEST: 60000; - readonly SHORT_REQUEST: 10000; - readonly LONG_REQUEST: 300000; - readonly STREAMING: 0; -}; -type TimeoutValue = typeof TIMEOUTS[keyof typeof TIMEOUTS]; -/** - * Retry configuration defaults - */ -declare const RETRY_CONFIG: { - readonly DEFAULT_MAX_RETRIES: 3; - readonly INITIAL_DELAY: 1000; - readonly MAX_DELAY: 30000; - readonly BACKOFF_FACTOR: 2; -}; -type RetryConfigValue = typeof RETRY_CONFIG[keyof typeof RETRY_CONFIG]; - -/** - * SignalR hub connection states - */ -declare enum HubConnectionState { - Disconnected = "Disconnected", - Connecting = "Connecting", - Connected = "Connected", - Disconnecting = "Disconnecting", - Reconnecting = "Reconnecting" -} -/** - * SignalR logging levels - */ -declare enum SignalRLogLevel { - Trace = 0, - Debug = 1, - Information = 2, - Warning = 3, - Error = 4, - Critical = 5, - None = 6 -} -/** - * HTTP transport types for SignalR - */ -declare enum HttpTransportType { - None = 0, - WebSockets = 1, - ServerSentEvents = 2, - LongPolling = 4 -} -/** - * Default transport configuration - */ -declare const DefaultTransports: number; -/** - * SignalR protocol types - */ -declare enum SignalRProtocolType { - /** - * JSON protocol (default) - */ - Json = "json", - /** - * MessagePack binary protocol with compression - */ - MessagePack = "messagepack" -} -/** - * Base SignalR connection options - */ -interface SignalRConnectionOptions { - /** - * Logging level - */ - logLevel?: SignalRLogLevel; - /** - * Transport types to use - */ - transport?: HttpTransportType; - /** - * Headers to include with requests - */ - headers?: Record; - /** - * Access token factory for authentication - */ - accessTokenFactory?: () => string | Promise; - /** - * Close timeout in milliseconds - */ - closeTimeout?: number; - /** - * Reconnection delay intervals in milliseconds - */ - reconnectionDelay?: number[]; - /** - * Server timeout in milliseconds - */ - serverTimeout?: number; - /** - * Keep-alive interval in milliseconds - */ - keepAliveInterval?: number; - /** - * Protocol to use for SignalR communication - * @default SignalRProtocolType.Json - */ - protocol?: SignalRProtocolType; -} -/** - * Authentication configuration for SignalR connections - */ -interface SignalRAuthConfig { - /** - * Authentication token or key - */ - authToken: string; - /** - * Authentication type (e.g., 'master', 'virtual') - */ - authType: 'master' | 'virtual'; - /** - * Additional headers for authentication - */ - additionalHeaders?: Record; -} -/** - * SignalR hub method argument types for type safety - */ -type SignalRPrimitive = string | number | boolean | null | undefined; -type SignalRValue = SignalRPrimitive | SignalRArgs | SignalRPrimitive[]; -interface SignalRArgs { - [key: string]: SignalRValue; -} - -/** - * Base configuration for SignalR connections - */ -interface BaseSignalRConfig { - /** - * Base URL for the SignalR hub - */ - baseUrl: string; - /** - * Authentication configuration - */ - auth: SignalRAuthConfig; - /** - * Connection options - */ - options?: SignalRConnectionOptions; - /** - * User agent string - */ - userAgent?: string; -} -/** - * Base class for SignalR hub connections with automatic reconnection and error handling. - * This abstract class provides common functionality for both Admin and Core SDKs. - */ -declare abstract class BaseSignalRConnection { - protected connection?: signalR.HubConnection; - protected readonly config: BaseSignalRConfig; - protected connectionReadyPromise: Promise; - private connectionReadyResolve?; - private connectionReadyReject?; - private disposed; - /** - * Gets the hub path for this connection type. - */ - protected abstract get hubPath(): string; - constructor(config: BaseSignalRConfig); - /** - * Gets whether the connection is established and ready for use. - */ - get isConnected(): boolean; - /** - * Gets the current connection state. - */ - get state(): HubConnectionState; - /** - * Event handlers - */ - onConnected?: () => Promise; - onDisconnected?: (error?: Error) => Promise; - onReconnecting?: (error?: Error) => Promise; - onReconnected?: (connectionId?: string) => Promise; - /** - * Establishes the SignalR connection. - */ - protected getConnection(): Promise; - /** - * Configures hub-specific event handlers. Override in derived classes. - */ - protected abstract configureHubHandlers(connection: signalR.HubConnection): void; - /** - * Maps transport type enum to SignalR transport. - */ - protected mapTransportType(transport: HttpTransportType): signalR.HttpTransportType; - /** - * Maps log level enum to SignalR log level. - */ - protected mapLogLevel(level: SignalRLogLevel): signalR.LogLevel; - /** - * Builds headers for the connection based on configuration. - */ - private buildHeaders; - /** - * Waits for the connection to be ready. - */ - waitForReady(): Promise; - /** - * Invokes a method on the hub with proper error handling. - */ - protected invoke(methodName: string, ...args: unknown[]): Promise; - /** - * Sends a message to the hub without expecting a response. - */ - protected send(methodName: string, ...args: unknown[]): Promise; - /** - * Disconnects the SignalR connection. - */ - disconnect(): Promise; - /** - * Disposes of the connection and cleans up resources. - */ - dispose(): Promise; -} - -/** - * Logger interface for client logging - */ -interface Logger { - debug(message: string, ...args: unknown[]): void; - info(message: string, ...args: unknown[]): void; - warn(message: string, ...args: unknown[]): void; - error(message: string, ...args: unknown[]): void; -} -/** - * Cache provider interface for client-side caching - */ -interface CacheProvider { - get(key: string): Promise; - set(key: string, value: T, ttl?: number): Promise; - delete(key: string): Promise; - clear(): Promise; -} -/** - * Base retry configuration interface - * - * Note: The Admin and Core SDKs have different retry strategies: - * - Admin SDK uses simple fixed delay retry - * - Core SDK uses exponential backoff - * - * This base interface supports both patterns. - */ -interface RetryConfig { - /** - * Maximum number of retry attempts - */ - maxRetries: number; - /** - * For Admin SDK: Fixed delay between retries in milliseconds - * For Core SDK: Initial delay for exponential backoff - */ - retryDelay?: number; - /** - * For Core SDK: Initial delay for exponential backoff - */ - initialDelay?: number; - /** - * For Core SDK: Maximum delay between retries - */ - maxDelay?: number; - /** - * For Core SDK: Backoff multiplication factor - */ - factor?: number; - /** - * Custom retry condition function - */ - retryCondition?: (error: unknown) => boolean; -} -/** - * HTTP error class - */ -declare class HttpError extends Error { - code?: string; - response?: { - status: number; - data: unknown; - headers: Record; - }; - request?: unknown; - config?: { - url?: string; - method?: string; - _retry?: number; - }; - constructor(message: string, code?: string); -} -/** - * Request configuration information - */ -interface RequestConfigInfo { - method: string; - url: string; - headers: Record; - data?: unknown; - params?: Record; -} -/** - * Response information - */ -interface ResponseInfo { - status: number; - statusText: string; - headers: Record; - data: unknown; - config: RequestConfigInfo; -} -/** - * Base client lifecycle callbacks - */ -interface ClientLifecycleCallbacks { - /** - * Callback invoked on any error - */ - onError?: (error: Error) => void; - /** - * Callback invoked before each request - */ - onRequest?: (config: RequestConfigInfo) => void | Promise; - /** - * Callback invoked after each response - */ - onResponse?: (response: ResponseInfo) => void | Promise; -} -/** - * Base client configuration options - */ -interface BaseClientOptions extends ClientLifecycleCallbacks { - /** - * Request timeout in milliseconds - */ - timeout?: number; - /** - * Retry configuration - */ - retries?: number | RetryConfig; - /** - * Logger instance for client logging - */ - logger?: Logger; - /** - * Cache provider for response caching - */ - cache?: CacheProvider; - /** - * Custom headers to include with all requests - */ - headers?: Record; - /** - * Custom retry delays in milliseconds (overrides retry config) - * @default [1000, 2000, 4000, 8000, 16000] - */ - retryDelay?: number[]; - /** - * Custom function to validate response status - */ - validateStatus?: (status: number) => boolean; - /** - * Enable debug mode - */ - debug?: boolean; -} - -/** - * SignalR client configuration - */ -interface SignalRConfig { - /** - * Whether SignalR is enabled - * @default true - */ - enabled?: boolean; - /** - * Whether to automatically connect on client initialization - * @default true - */ - autoConnect?: boolean; - /** - * Reconnection delays in milliseconds (exponential backoff) - * @default [0, 2000, 10000, 30000] - */ - reconnectDelay?: number[]; - /** - * SignalR logging level - * @default SignalRLogLevel.Information - */ - logLevel?: SignalRLogLevel; - /** - * HTTP transport type - * @default HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling - */ - transport?: HttpTransportType; - /** - * Custom headers for SignalR connections - */ - headers?: Record; - /** - * Connection timeout in milliseconds - * @default 30000 - */ - connectionTimeout?: number; -} - -/** - * Retry strategy types and utilities for SDK HTTP clients - * Supports both fixed delay (Admin SDK) and exponential backoff (Gateway SDK) patterns - */ -/** - * Type of retry strategy to use - */ -declare enum RetryStrategyType { - /** Fixed delay between retries (Admin SDK pattern) */ - FIXED_DELAY = "fixed_delay", - /** Exponential backoff with optional jitter (Gateway SDK pattern) */ - EXPONENTIAL_BACKOFF = "exponential_backoff", - /** Custom array of delays */ - CUSTOM_DELAYS = "custom_delays" -} -/** - * Fixed delay retry configuration - * Used by Admin SDK for simple retry patterns - */ -interface FixedDelayConfig { - type: RetryStrategyType.FIXED_DELAY; - /** Maximum number of retry attempts */ - maxRetries: number; - /** Delay between retries in milliseconds */ - delayMs: number; - /** Optional custom condition to determine if error is retryable */ - retryCondition?: (error: unknown) => boolean; -} -/** - * Exponential backoff retry configuration - * Used by Gateway SDK for sophisticated retry patterns - */ -interface ExponentialBackoffConfig { - type: RetryStrategyType.EXPONENTIAL_BACKOFF; - /** Maximum number of retry attempts */ - maxRetries: number; - /** Initial delay in milliseconds */ - initialDelayMs: number; - /** Maximum delay cap in milliseconds */ - maxDelayMs: number; - /** Multiplication factor for each retry */ - factor: number; - /** Whether to add random jitter to prevent thundering herd */ - jitter?: boolean; - /** Optional custom condition to determine if error is retryable */ - retryCondition?: (error: unknown) => boolean; -} -/** - * Custom delays retry configuration - * Allows specifying exact delay for each retry attempt - */ -interface CustomDelaysConfig { - type: RetryStrategyType.CUSTOM_DELAYS; - /** Array of delays in milliseconds for each retry attempt */ - delays: number[]; - /** Optional custom condition to determine if error is retryable */ - retryCondition?: (error: unknown) => boolean; -} -/** - * Union type for all retry strategy configurations - */ -type RetryStrategy = FixedDelayConfig | ExponentialBackoffConfig | CustomDelaysConfig; -/** - * Calculate the delay for a retry attempt based on the strategy - * @param strategy - The retry strategy configuration - * @param attempt - The current attempt number (1-based) - * @returns Delay in milliseconds before the next retry - */ -declare function calculateRetryDelay(strategy: RetryStrategy, attempt: number): number; -/** - * Get the maximum number of retries for a strategy - * @param strategy - The retry strategy configuration - * @returns Maximum number of retry attempts - */ -declare function getMaxRetries(strategy: RetryStrategy): number; -/** - * Check if an error should be retried based on the strategy's condition - * @param strategy - The retry strategy configuration - * @param error - The error to check - * @returns Whether the error should trigger a retry - */ -declare function shouldRetryWithStrategy(strategy: RetryStrategy, error: unknown): boolean; -/** - * Default retry strategies for each SDK type - */ -declare const DEFAULT_RETRY_STRATEGIES: { - /** Gateway SDK default: exponential backoff with jitter */ - gateway: ExponentialBackoffConfig; - /** Admin SDK default: fixed delay */ - admin: FixedDelayConfig; -}; - -/** - * Base client configuration types for SDK HTTP clients - */ - -/** - * Base configuration shared by all API clients - */ -interface BaseApiClientConfig extends ClientLifecycleCallbacks { - /** Base URL for API requests (trailing slash will be removed) */ - baseUrl: string; - /** Request timeout in milliseconds (default: 60000) */ - timeout?: number; - /** Default headers included with all requests */ - defaultHeaders?: Record; - /** Retry strategy configuration */ - retryStrategy?: RetryStrategy; - /** Enable debug logging (default: false) */ - debug?: boolean; - /** Optional logger for structured logging */ - logger?: Logger; - /** Optional cache provider for response caching */ - cache?: CacheProvider; -} -/** - * Configuration for clients that support caching - * @deprecated Use BaseApiClientConfig with optional cache property - */ -interface CacheableClientConfig extends BaseApiClientConfig { - /** Cache provider for response caching */ - cache?: CacheProvider; -} -/** - * Configuration for clients that support logging - * @deprecated Use BaseApiClientConfig with optional logger property - */ -interface LoggableClientConfig extends BaseApiClientConfig { - /** Logger instance for structured logging */ - logger?: Logger; -} -/** - * Full-featured client configuration with all optional features - * Used by Admin SDK which supports both caching and logging - */ -interface FullFeaturedClientConfig extends BaseApiClientConfig { - /** Cache provider for response caching */ - cache?: CacheProvider; - /** Logger instance for structured logging */ - logger?: Logger; -} - -/** - * Abstract base API client providing common HTTP functionality - * - * SDK-specific clients extend this class and implement: - * - getAuthHeaders(): Returns authentication headers - * - getDefaultRetryStrategy(): Returns default retry strategy - * - * Template methods that can be overridden: - * - handleErrorResponse(): SDK-specific error parsing - * - shouldRetry(): SDK-specific retry logic - * - getRetryDelay(): SDK-specific delay calculation - */ - -/** - * Request options for individual requests - */ -interface BaseRequestOptions { - /** Additional headers for this request */ - headers?: Record; - /** AbortSignal for request cancellation */ - signal?: AbortSignal; - /** Request timeout in milliseconds (overrides client default) */ - timeout?: number; - /** Expected response type */ - responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; -} -/** - * Abstract base API client providing common HTTP functionality - * - * Both Gateway SDK and Admin SDK extend this class. - */ -declare abstract class BaseApiClient { - /** Base URL for all requests (without trailing slash) */ - protected readonly baseUrl: string; - /** Default timeout in milliseconds */ - protected readonly timeout: number; - /** Default headers included with all requests */ - protected readonly defaultHeaders: Record; - /** Retry strategy configuration */ - protected readonly retryStrategy: RetryStrategy; - /** Enable debug logging */ - protected readonly debug: boolean; - protected readonly onError?: (error: Error) => void; - protected readonly onRequest?: (config: RequestConfigInfo) => void | Promise; - protected readonly onResponse?: (response: ResponseInfo) => void | Promise; - protected readonly logger?: Logger; - protected readonly cache?: CacheProvider; - constructor(config: BaseApiClientConfig); - /** - * Returns authentication headers for this SDK - * - * Gateway SDK returns: { Authorization: 'Bearer ...' } - * Admin SDK returns: { 'X-Master-Key': '...' } - */ - protected abstract getAuthHeaders(): Record; - /** - * Returns default retry strategy for this SDK - * - * Gateway SDK uses exponential backoff with jitter - * Admin SDK uses fixed delay - */ - protected abstract getDefaultRetryStrategy(): RetryStrategy; - /** - * Transform error response into appropriate error type - * Subclasses can override for SDK-specific error handling - * - * @param response - The failed Response object - * @returns An Error to throw - */ - protected handleErrorResponse(response: Response): Promise; - /** - * Determine if an error should be retried - * Subclasses can override for SDK-specific retry logic - * - * @param error - The error that occurred - * @param attempt - Current attempt number (1-based) - * @returns Whether to retry the request - */ - protected shouldRetry(error: unknown, attempt: number): boolean; - /** - * Calculate delay for a retry attempt - * Subclasses can override for special cases (e.g., retry-after headers) - * - * @param error - The error that triggered the retry - * @param attempt - Current attempt number (1-based) - * @returns Delay in milliseconds before next retry - */ - protected getRetryDelay(_error: unknown, attempt: number): number; - /** - * Main request method with retry logic - */ - protected request(url: string, options?: BaseRequestOptions & { - method?: HttpMethod; - body?: TRequest; - }): Promise; - /** - * Type-safe GET request - */ - protected get(url: string, options?: BaseRequestOptions): Promise; - /** - * Type-safe POST request - */ - protected post(url: string, data?: TRequest, options?: BaseRequestOptions): Promise; - /** - * Type-safe PUT request - */ - protected put(url: string, data?: TRequest, options?: BaseRequestOptions): Promise; - /** - * Type-safe PATCH request - */ - protected patch(url: string, data?: TRequest, options?: BaseRequestOptions): Promise; - /** - * Type-safe DELETE request - */ - protected delete(url: string, options?: BaseRequestOptions): Promise; - /** - * Execute request with retry logic - */ - private executeWithRetry; - /** - * Build full URL from path - */ - private buildUrl; - /** - * Build headers including auth, defaults, and additional headers - */ - private buildHeaders; - /** - * Log a message using the configured logger or console in debug mode - */ - protected log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: unknown[]): void; - /** - * Sleep for a specified duration - */ - private sleep; - /** - * Get a value from cache - * Returns null if cache is not configured or key is not found - */ - protected getFromCache(key: string): Promise; - /** - * Set a value in cache - * No-op if cache is not configured - */ - protected setCache(key: string, value: unknown, ttl?: number): Promise; - /** - * Execute a function with caching - * Returns cached value if available, otherwise executes function and caches result - */ - protected withCache(cacheKey: string, fn: () => Promise, ttl?: number): Promise; - /** - * Generate a cache key from resource and identifiers - */ - protected getCacheKey(resource: string, ...identifiers: (string | number | Record | undefined)[]): string; -} - -/** - * Circuit breaker types and interfaces - * - * Provides types for implementing the circuit breaker pattern to prevent - * cascading failures and protect against sustained service degradation. - */ -/** - * Circuit breaker states following the standard pattern - */ -declare enum CircuitState { - /** Normal operation - requests pass through, failures tracked */ - CLOSED = "closed", - /** Circuit tripped - requests are blocked/rejected immediately */ - OPEN = "open", - /** Testing recovery - limited requests allowed to test if service recovered */ - HALF_OPEN = "half_open" -} -/** - * Configuration options for the circuit breaker - */ -interface CircuitBreakerConfig { - /** Number of consecutive failures to trip the circuit (default: 3) */ - failureThreshold?: number; - /** Time window in milliseconds for counting failures (default: 60000) */ - failureWindowMs?: number; - /** Time in milliseconds to wait before transitioning from OPEN to HALF_OPEN (default: 30000) */ - resetTimeoutMs?: number; - /** Number of successful requests in HALF_OPEN to close circuit (default: 1) */ - successThreshold?: number; - /** Enable debug logging (default: false) */ - enableLogging?: boolean; - /** Custom function to determine if an error should count as a failure */ - shouldCountAsFailure?: (error: unknown) => boolean; -} -/** - * Statistics about the circuit breaker state - */ -interface CircuitBreakerStats { - /** Current state of the circuit */ - state: CircuitState; - /** Number of consecutive failures in current window */ - consecutiveFailures: number; - /** Total failures since last reset */ - totalFailures: number; - /** Total successes since last reset */ - totalSuccesses: number; - /** Timestamp when circuit was opened (null if closed) */ - circuitOpenedAt: number | null; - /** Time remaining until HALF_OPEN transition in ms (null if not OPEN) */ - timeUntilHalfOpen: number | null; - /** Timestamp of last failure */ - lastFailureAt: number | null; - /** Timestamp of last success */ - lastSuccessAt: number | null; - /** Number of requests rejected while OPEN */ - rejectedRequests: number; -} -/** - * Callbacks for circuit breaker state changes - */ -interface CircuitBreakerCallbacks { - /** Called when circuit transitions to OPEN state */ - onOpen?: (stats: CircuitBreakerStats, error: unknown) => void; - /** Called when circuit transitions to HALF_OPEN state */ - onHalfOpen?: (stats: CircuitBreakerStats) => void; - /** Called when circuit transitions to CLOSED state */ - onClose?: (stats: CircuitBreakerStats) => void; - /** Called when a request is rejected due to OPEN circuit */ - onRejected?: (stats: CircuitBreakerStats) => void; - /** Called on any state change */ - onStateChange?: (oldState: CircuitState, newState: CircuitState, stats: CircuitBreakerStats) => void; -} - -/** - * Circuit breaker error types - */ - -/** - * Error thrown when circuit breaker is open and request is rejected - */ -declare class CircuitBreakerOpenError extends ConduitError { - /** Current circuit breaker state */ - readonly circuitState: CircuitState; - /** Time until circuit transitions to HALF_OPEN (milliseconds) */ - readonly timeUntilHalfOpen: number | null; - /** Circuit breaker statistics at time of rejection */ - readonly stats: CircuitBreakerStats; - constructor(message: string, stats: CircuitBreakerStats, timeUntilHalfOpen: number | null); -} -/** - * Type guard for CircuitBreakerOpenError - */ -declare function isCircuitBreakerOpenError(error: unknown): error is CircuitBreakerOpenError; - -/** - * Circuit breaker implementation for preventing cascading failures - * - * Implements the circuit breaker pattern with three states: - * - CLOSED: Normal operation, counting failures - * - OPEN: Circuit tripped, rejecting requests - * - HALF_OPEN: Testing recovery with limited requests - */ - -/** - * Circuit breaker implementation for preventing cascading failures - * - * State machine: - * - CLOSED: Normal operation, counting failures - * - OPEN: Circuit tripped, rejecting requests - * - HALF_OPEN: Testing recovery with limited requests - */ -declare class CircuitBreaker { - private readonly config; - private readonly callbacks; - private state; - private failures; - private halfOpenSuccesses; - private totalFailures; - private totalSuccesses; - private rejectedRequests; - private circuitOpenedAt; - private lastFailureAt; - private lastSuccessAt; - constructor(config?: CircuitBreakerConfig, callbacks?: CircuitBreakerCallbacks); - /** - * Get current state of the circuit - * Automatically transitions OPEN -> HALF_OPEN after timeout - */ - getState(): CircuitState; - /** - * Get circuit breaker statistics - */ - getStats(): CircuitBreakerStats; - /** - * Check if a request can proceed - * Returns true if circuit is CLOSED or HALF_OPEN - */ - canExecute(): boolean; - /** - * Check if request should proceed, throwing if circuit is open - * @throws CircuitBreakerOpenError if circuit is OPEN - */ - checkOpen(): void; - /** - * Record a successful request - */ - recordSuccess(): void; - /** - * Record a failed request - */ - recordFailure(error: unknown): void; - /** - * Manually reset the circuit to CLOSED state - * Use with caution - typically for testing or admin override - */ - reset(): void; - private transitionTo; - private pruneOldFailures; - private getConsecutiveFailuresInWindow; - private calculateTimeUntilHalfOpen; - private log; -} - -export { type ApiResponse, AuthError, AuthenticationError, AuthorizationError, BaseApiClient, type BaseApiClientConfig, type BaseClientOptions, type BaseRequestOptions, type BaseSignalRConfig, BaseSignalRConnection, type BatchOperationParams, CONTENT_TYPES, type CacheProvider, type CacheableClientConfig, CircuitBreaker, type CircuitBreakerCallbacks, type CircuitBreakerConfig, CircuitBreakerOpenError, type CircuitBreakerStats, CircuitState, type ClientLifecycleCallbacks, ConduitError, ConflictError, type ContentType, type CustomDelaysConfig, DEFAULT_RETRY_STRATEGIES, type DateRange, DefaultTransports, ERROR_CODES, type ErrorCode, type ErrorResponse, type ErrorResponseFormat, type ExponentialBackoffConfig, type ExtendedRequestInit, type FilterOptions, type FixedDelayConfig, type FullFeaturedClientConfig, HTTP_HEADERS, HTTP_STATUS, HttpError, type HttpHeader, HttpMethod, type HttpStatusCode, HttpTransportType, HubConnectionState, InsufficientBalanceError, type LoggableClientConfig, type Logger, type ModelCapabilities, ModelCapability, type ModelCapabilityInfo, type ModelConstraints, NetworkError, NotFoundError, NotImplementedError, type PagedResponse, type PaginatedResponse, type PaginationParams, type PerformanceMetrics, RETRY_CONFIG, RateLimitError, type RequestConfigInfo, type RequestOptions, type ResponseInfo, ResponseParser, type RetryConfig, type RetryConfigValue, type RetryStrategy, RetryStrategyType, type SearchParams, ServerError, type SignalRArgs, type SignalRAuthConfig, type SignalRConfig, type SignalRConnectionOptions, SignalRLogLevel, SignalRProtocolType, type SignalRValue, type SortDirection, type SortOptions, StreamError, TIMEOUTS, type TimeRangeParams, TimeoutError, type TimeoutValue, type Usage, ValidationError, calculateRetryDelay, createErrorFromResponse, deserializeError, getCapabilityCategory, getCapabilityDisplayName, getErrorMessage, getErrorStatusCode, getMaxRetries, handleApiError, isAuthError, isAuthorizationError, isCircuitBreakerOpenError, isConduitError, isConflictError, isErrorLike, isHttpError, isHttpMethod, isHttpNetworkError, isInsufficientBalanceError, isNetworkError, isNotFoundError, isRateLimitError, isSerializedConduitError, isServerError, isStreamError, isTimeoutError, isValidationError, serializeError, shouldRetryWithStrategy }; diff --git a/SDKs/Node/Common/dist/index.js b/SDKs/Node/Common/dist/index.js deleted file mode 100644 index 37d2d78c4..000000000 --- a/SDKs/Node/Common/dist/index.js +++ /dev/null @@ -1,1555 +0,0 @@ -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/index.ts -var index_exports = {}; -__export(index_exports, { - AuthError: () => AuthError, - AuthenticationError: () => AuthenticationError, - AuthorizationError: () => AuthorizationError, - BaseApiClient: () => BaseApiClient, - BaseSignalRConnection: () => BaseSignalRConnection, - CONTENT_TYPES: () => CONTENT_TYPES, - CircuitBreaker: () => CircuitBreaker, - CircuitBreakerOpenError: () => CircuitBreakerOpenError, - CircuitState: () => CircuitState, - ConduitError: () => ConduitError, - ConflictError: () => ConflictError, - DEFAULT_RETRY_STRATEGIES: () => DEFAULT_RETRY_STRATEGIES, - DefaultTransports: () => DefaultTransports, - ERROR_CODES: () => ERROR_CODES, - HTTP_HEADERS: () => HTTP_HEADERS, - HTTP_STATUS: () => HTTP_STATUS, - HttpError: () => HttpError, - HttpMethod: () => HttpMethod, - HttpTransportType: () => HttpTransportType, - HubConnectionState: () => HubConnectionState, - InsufficientBalanceError: () => InsufficientBalanceError, - ModelCapability: () => ModelCapability, - NetworkError: () => NetworkError, - NotFoundError: () => NotFoundError, - NotImplementedError: () => NotImplementedError, - RETRY_CONFIG: () => RETRY_CONFIG, - RateLimitError: () => RateLimitError, - ResponseParser: () => ResponseParser, - RetryStrategyType: () => RetryStrategyType, - ServerError: () => ServerError, - SignalRLogLevel: () => SignalRLogLevel, - SignalRProtocolType: () => SignalRProtocolType, - StreamError: () => StreamError, - TIMEOUTS: () => TIMEOUTS, - TimeoutError: () => TimeoutError, - ValidationError: () => ValidationError, - calculateRetryDelay: () => calculateRetryDelay, - createErrorFromResponse: () => createErrorFromResponse, - deserializeError: () => deserializeError, - getCapabilityCategory: () => getCapabilityCategory, - getCapabilityDisplayName: () => getCapabilityDisplayName, - getErrorMessage: () => getErrorMessage, - getErrorStatusCode: () => getErrorStatusCode, - getMaxRetries: () => getMaxRetries, - handleApiError: () => handleApiError, - isAuthError: () => isAuthError, - isAuthorizationError: () => isAuthorizationError, - isCircuitBreakerOpenError: () => isCircuitBreakerOpenError, - isConduitError: () => isConduitError, - isConflictError: () => isConflictError, - isErrorLike: () => isErrorLike, - isHttpError: () => isHttpError, - isHttpMethod: () => isHttpMethod, - isHttpNetworkError: () => isHttpNetworkError, - isInsufficientBalanceError: () => isInsufficientBalanceError, - isNetworkError: () => isNetworkError, - isNotFoundError: () => isNotFoundError, - isRateLimitError: () => isRateLimitError, - isSerializedConduitError: () => isSerializedConduitError, - isServerError: () => isServerError, - isStreamError: () => isStreamError, - isTimeoutError: () => isTimeoutError, - isValidationError: () => isValidationError, - serializeError: () => serializeError, - shouldRetryWithStrategy: () => shouldRetryWithStrategy -}); -module.exports = __toCommonJS(index_exports); - -// src/types/capabilities.ts -var ModelCapability = /* @__PURE__ */ ((ModelCapability2) => { - ModelCapability2["CHAT"] = "chat"; - ModelCapability2["VISION"] = "vision"; - ModelCapability2["IMAGE_GENERATION"] = "image-generation"; - ModelCapability2["IMAGE_EDIT"] = "image-edit"; - ModelCapability2["IMAGE_VARIATION"] = "image-variation"; - ModelCapability2["AUDIO_TRANSCRIPTION"] = "audio-transcription"; - ModelCapability2["TEXT_TO_SPEECH"] = "text-to-speech"; - ModelCapability2["REALTIME_AUDIO"] = "realtime-audio"; - ModelCapability2["EMBEDDINGS"] = "embeddings"; - ModelCapability2["VIDEO_GENERATION"] = "video-generation"; - return ModelCapability2; -})(ModelCapability || {}); -function getCapabilityDisplayName(capability) { - switch (capability) { - case "chat" /* CHAT */: - return "Chat Completion"; - case "vision" /* VISION */: - return "Vision (Image Understanding)"; - case "image-generation" /* IMAGE_GENERATION */: - return "Image Generation"; - case "image-edit" /* IMAGE_EDIT */: - return "Image Editing"; - case "image-variation" /* IMAGE_VARIATION */: - return "Image Variation"; - case "audio-transcription" /* AUDIO_TRANSCRIPTION */: - return "Audio Transcription"; - case "text-to-speech" /* TEXT_TO_SPEECH */: - return "Text-to-Speech"; - case "realtime-audio" /* REALTIME_AUDIO */: - return "Realtime Audio"; - case "embeddings" /* EMBEDDINGS */: - return "Embeddings"; - case "video-generation" /* VIDEO_GENERATION */: - return "Video Generation"; - default: - return capability; - } -} -function getCapabilityCategory(capability) { - switch (capability) { - case "chat" /* CHAT */: - case "embeddings" /* EMBEDDINGS */: - return "text"; - case "vision" /* VISION */: - case "image-generation" /* IMAGE_GENERATION */: - case "image-edit" /* IMAGE_EDIT */: - case "image-variation" /* IMAGE_VARIATION */: - return "vision"; - case "audio-transcription" /* AUDIO_TRANSCRIPTION */: - case "text-to-speech" /* TEXT_TO_SPEECH */: - case "realtime-audio" /* REALTIME_AUDIO */: - return "audio"; - case "video-generation" /* VIDEO_GENERATION */: - return "video"; - default: - return "text"; - } -} - -// src/errors/index.ts -var ConduitError = class _ConduitError extends Error { - statusCode; - code; - context; - // Admin SDK specific fields - details; - endpoint; - method; - // Core SDK specific fields - type; - param; - constructor(message, statusCode = 500, code = "INTERNAL_ERROR", context) { - super(message); - this.name = this.constructor.name; - this.statusCode = statusCode; - this.code = code; - this.context = context; - if (context) { - this.details = context.details; - this.endpoint = context.endpoint; - this.method = context.method; - this.type = context.type; - this.param = context.param; - } - Object.setPrototypeOf(this, new.target.prototype); - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } - toJSON() { - return { - name: this.name, - message: this.message, - statusCode: this.statusCode, - code: this.code, - context: this.context, - details: this.details, - endpoint: this.endpoint, - method: this.method, - type: this.type, - param: this.param, - timestamp: (/* @__PURE__ */ new Date()).toISOString() - }; - } - // Helper method for Next.js serialization - toSerializable() { - return { - isConduitError: true, - ...this.toJSON() - }; - } - // Static method to reconstruct from serialized error - static fromSerializable(data) { - if (!data || typeof data !== "object" || !("isConduitError" in data) || !data.isConduitError) { - throw new Error("Invalid serialized ConduitError"); - } - const errorData = data; - const error = new _ConduitError( - errorData.message, - errorData.statusCode, - errorData.code, - errorData.context - ); - if (errorData.details !== void 0) error.details = errorData.details; - if (errorData.endpoint !== void 0) error.endpoint = errorData.endpoint; - if (errorData.method !== void 0) error.method = errorData.method; - if (errorData.type !== void 0) error.type = errorData.type; - if (errorData.param !== void 0) error.param = errorData.param; - return error; - } -}; -var AuthError = class extends ConduitError { - constructor(message = "Authentication failed", context) { - super(message, 401, "AUTH_ERROR", context); - } -}; -var AuthenticationError = class extends AuthError { -}; -var AuthorizationError = class extends ConduitError { - constructor(message = "Access forbidden", context) { - super(message, 403, "AUTHORIZATION_ERROR", context); - } -}; -var ValidationError = class extends ConduitError { - field; - constructor(message = "Validation failed", context) { - super(message, 400, "VALIDATION_ERROR", context); - this.field = context?.field; - } -}; -var NotFoundError = class extends ConduitError { - constructor(message = "Resource not found", context) { - super(message, 404, "NOT_FOUND", context); - } -}; -var ConflictError = class extends ConduitError { - constructor(message = "Resource conflict", context) { - super(message, 409, "CONFLICT_ERROR", context); - } -}; -var InsufficientBalanceError = class extends ConduitError { - balance; - requiredAmount; - constructor(message = "Insufficient balance to complete request", context) { - super(message, 402, "INSUFFICIENT_BALANCE", context); - this.balance = context?.balance; - this.requiredAmount = context?.requiredAmount; - } -}; -var RateLimitError = class extends ConduitError { - retryAfter; - constructor(message = "Rate limit exceeded", retryAfter, context) { - super(message, 429, "RATE_LIMIT_ERROR", { ...context, retryAfter }); - this.retryAfter = retryAfter; - } -}; -var ServerError = class extends ConduitError { - constructor(message = "Internal server error", context) { - super(message, 500, "SERVER_ERROR", context); - } -}; -var NetworkError = class extends ConduitError { - constructor(message = "Network error", context) { - super(message, 0, "NETWORK_ERROR", context); - } -}; -var TimeoutError = class extends ConduitError { - constructor(message = "Request timeout", context) { - super(message, 408, "TIMEOUT_ERROR", context); - } -}; -var NotImplementedError = class extends ConduitError { - constructor(message, context) { - super(message, 501, "NOT_IMPLEMENTED", context); - } -}; -var StreamError = class extends ConduitError { - constructor(message = "Stream processing failed", context) { - super(message, 500, "STREAM_ERROR", context); - } -}; -function isConduitError(error) { - return error instanceof ConduitError; -} -function isAuthError(error) { - return error instanceof AuthError || error instanceof AuthenticationError; -} -function isAuthorizationError(error) { - return error instanceof AuthorizationError; -} -function isValidationError(error) { - return error instanceof ValidationError; -} -function isNotFoundError(error) { - return error instanceof NotFoundError; -} -function isConflictError(error) { - return error instanceof ConflictError; -} -function isInsufficientBalanceError(error) { - return error instanceof InsufficientBalanceError; -} -function isRateLimitError(error) { - return error instanceof RateLimitError; -} -function isNetworkError(error) { - return error instanceof NetworkError; -} -function isStreamError(error) { - return error instanceof StreamError; -} -function isTimeoutError(error) { - return error instanceof TimeoutError; -} -function isServerError(error) { - return isConduitError(error) && error.statusCode !== void 0 && error.statusCode >= 500; -} -function isSerializedConduitError(data) { - return typeof data === "object" && data !== null && "isConduitError" in data && data.isConduitError === true; -} -function isHttpError(error) { - return typeof error === "object" && error !== null && "response" in error && typeof error.response === "object"; -} -function isHttpNetworkError(error) { - return typeof error === "object" && error !== null && "request" in error && !("response" in error); -} -function isErrorLike(error) { - return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"; -} -function serializeError(error) { - if (isConduitError(error)) { - return error.toSerializable(); - } - if (error instanceof Error) { - return { - isError: true, - name: error.name, - message: error.message, - stack: process.env.NODE_ENV === "development" ? error.stack : void 0 - }; - } - return { - isError: true, - message: String(error) - }; -} -function deserializeError(data) { - if (isSerializedConduitError(data)) { - return ConduitError.fromSerializable(data); - } - if (typeof data === "object" && data !== null && "isError" in data) { - const errorData = data; - const error = new Error(errorData.message || "Unknown error"); - if (errorData.name) error.name = errorData.name; - if (errorData.stack) error.stack = errorData.stack; - return error; - } - return new Error("Unknown error"); -} -function getErrorMessage(error) { - if (isConduitError(error)) { - return error.message; - } - if (error instanceof Error) { - return error.message; - } - return "An unexpected error occurred"; -} -function getErrorStatusCode(error) { - if (isConduitError(error)) { - return error.statusCode; - } - return 500; -} -function handleApiError(error, endpoint, method) { - const context = { - endpoint, - method - }; - if (isHttpError(error)) { - const { status, data } = error.response; - const errorData = data; - const baseMessage = errorData?.error || errorData?.message || error.message; - const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : ""; - const enhancedMessage = `${baseMessage}${endpointInfo}`; - context.details = errorData?.details || data; - switch (status) { - case 400: - throw new ValidationError(enhancedMessage, context); - case 401: - throw new AuthError(enhancedMessage, context); - case 402: - throw new InsufficientBalanceError(enhancedMessage, context); - case 403: - throw new AuthorizationError(enhancedMessage, context); - case 404: - throw new NotFoundError(enhancedMessage, context); - case 409: - throw new ConflictError(enhancedMessage, context); - case 429: { - const retryAfterHeader = error.response.headers["retry-after"]; - const retryAfter = typeof retryAfterHeader === "string" ? parseInt(retryAfterHeader, 10) : void 0; - throw new RateLimitError(enhancedMessage, retryAfter, context); - } - case 500: - case 502: - case 503: - case 504: - throw new ServerError(enhancedMessage, context); - default: - throw new ConduitError(enhancedMessage, status, `HTTP_${status}`, context); - } - } else if (isHttpNetworkError(error)) { - const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : ""; - context.code = error.code; - if (error.code === "ECONNABORTED") { - throw new TimeoutError(`Request timeout${endpointInfo}`, context); - } - throw new NetworkError(`Network error: No response received${endpointInfo}`, context); - } else if (isErrorLike(error)) { - context.originalError = error; - throw new ConduitError(error.message, 500, "UNKNOWN_ERROR", context); - } else { - context.originalError = error; - throw new ConduitError("Unknown error", 500, "UNKNOWN_ERROR", context); - } -} -function createErrorFromResponse(response, statusCode) { - const context = { - type: response.error.type, - param: response.error.param - }; - return new ConduitError( - response.error.message, - statusCode || 500, - response.error.code || "API_ERROR", - context - ); -} - -// src/http/types.ts -var HttpMethod = /* @__PURE__ */ ((HttpMethod2) => { - HttpMethod2["GET"] = "GET"; - HttpMethod2["POST"] = "POST"; - HttpMethod2["PUT"] = "PUT"; - HttpMethod2["DELETE"] = "DELETE"; - HttpMethod2["PATCH"] = "PATCH"; - HttpMethod2["HEAD"] = "HEAD"; - HttpMethod2["OPTIONS"] = "OPTIONS"; - return HttpMethod2; -})(HttpMethod || {}); -function isHttpMethod(method) { - return Object.values(HttpMethod).includes(method); -} - -// src/http/parser.ts -var ResponseParser = class { - /** - * Parses a fetch Response based on content type and response type hint - */ - static async parse(response, responseType) { - const contentLength = response.headers.get("content-length"); - if (contentLength === "0" || response.status === 204) { - return void 0; - } - if (responseType) { - switch (responseType) { - case "json": - return await response.json(); - case "text": - return await response.text(); - case "blob": - return await response.blob(); - case "arraybuffer": - return await response.arrayBuffer(); - case "stream": - if (!response.body) { - throw new Error("Response body is not a stream"); - } - return response.body; - default: { - const _exhaustive = responseType; - throw new Error(`Unknown response type: ${String(_exhaustive)}`); - } - } - } - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - return await response.json(); - } - if (contentType.includes("text/") || contentType.includes("application/xml")) { - return await response.text(); - } - if (contentType.includes("application/octet-stream") || contentType.includes("image/") || contentType.includes("audio/") || contentType.includes("video/")) { - return await response.blob(); - } - return await response.text(); - } - /** - * Creates a clean RequestInit object without custom properties - */ - static cleanRequestInit(init) { - const { responseType, timeout, metadata, ...standardInit } = init; - return standardInit; - } -}; - -// src/http/constants.ts -var HTTP_HEADERS = { - CONTENT_TYPE: "Content-Type", - AUTHORIZATION: "Authorization", - X_API_KEY: "X-API-Key", - USER_AGENT: "User-Agent", - X_CORRELATION_ID: "X-Correlation-Id", - RETRY_AFTER: "Retry-After", - ACCEPT: "Accept", - CACHE_CONTROL: "Cache-Control" -}; -var CONTENT_TYPES = { - JSON: "application/json", - FORM_DATA: "multipart/form-data", - FORM_URLENCODED: "application/x-www-form-urlencoded", - TEXT_PLAIN: "text/plain", - TEXT_STREAM: "text/event-stream" -}; -var HTTP_STATUS = { - // 2xx Success - OK: 200, - CREATED: 201, - NO_CONTENT: 204, - // 4xx Client Errors - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - TOO_MANY_REQUESTS: 429, - RATE_LIMITED: 429, - // Alias for Core SDK compatibility - // 5xx Server Errors - INTERNAL_SERVER_ERROR: 500, - INTERNAL_ERROR: 500, - // Alias for Admin SDK compatibility - BAD_GATEWAY: 502, - SERVICE_UNAVAILABLE: 503, - GATEWAY_TIMEOUT: 504 -}; -var ERROR_CODES = { - CONNECTION_ABORTED: "ECONNABORTED", - TIMEOUT: "ETIMEDOUT", - CONNECTION_RESET: "ECONNRESET", - NETWORK_UNREACHABLE: "ENETUNREACH", - CONNECTION_REFUSED: "ECONNREFUSED", - HOST_NOT_FOUND: "ENOTFOUND" -}; -var TIMEOUTS = { - DEFAULT_REQUEST: 6e4, - // 60 seconds - SHORT_REQUEST: 1e4, - // 10 seconds - LONG_REQUEST: 3e5, - // 5 minutes - STREAMING: 0 - // No timeout for streaming -}; -var RETRY_CONFIG = { - DEFAULT_MAX_RETRIES: 3, - INITIAL_DELAY: 1e3, - // 1 second - MAX_DELAY: 3e4, - // 30 seconds - BACKOFF_FACTOR: 2 -}; - -// src/signalr/types.ts -var HubConnectionState = /* @__PURE__ */ ((HubConnectionState3) => { - HubConnectionState3["Disconnected"] = "Disconnected"; - HubConnectionState3["Connecting"] = "Connecting"; - HubConnectionState3["Connected"] = "Connected"; - HubConnectionState3["Disconnecting"] = "Disconnecting"; - HubConnectionState3["Reconnecting"] = "Reconnecting"; - return HubConnectionState3; -})(HubConnectionState || {}); -var SignalRLogLevel = /* @__PURE__ */ ((SignalRLogLevel2) => { - SignalRLogLevel2[SignalRLogLevel2["Trace"] = 0] = "Trace"; - SignalRLogLevel2[SignalRLogLevel2["Debug"] = 1] = "Debug"; - SignalRLogLevel2[SignalRLogLevel2["Information"] = 2] = "Information"; - SignalRLogLevel2[SignalRLogLevel2["Warning"] = 3] = "Warning"; - SignalRLogLevel2[SignalRLogLevel2["Error"] = 4] = "Error"; - SignalRLogLevel2[SignalRLogLevel2["Critical"] = 5] = "Critical"; - SignalRLogLevel2[SignalRLogLevel2["None"] = 6] = "None"; - return SignalRLogLevel2; -})(SignalRLogLevel || {}); -var HttpTransportType = /* @__PURE__ */ ((HttpTransportType3) => { - HttpTransportType3[HttpTransportType3["None"] = 0] = "None"; - HttpTransportType3[HttpTransportType3["WebSockets"] = 1] = "WebSockets"; - HttpTransportType3[HttpTransportType3["ServerSentEvents"] = 2] = "ServerSentEvents"; - HttpTransportType3[HttpTransportType3["LongPolling"] = 4] = "LongPolling"; - return HttpTransportType3; -})(HttpTransportType || {}); -var DefaultTransports = 1 /* WebSockets */ | 2 /* ServerSentEvents */ | 4 /* LongPolling */; -var SignalRProtocolType = /* @__PURE__ */ ((SignalRProtocolType2) => { - SignalRProtocolType2["Json"] = "json"; - SignalRProtocolType2["MessagePack"] = "messagepack"; - return SignalRProtocolType2; -})(SignalRProtocolType || {}); - -// src/signalr/BaseSignalRConnection.ts -var signalR = __toESM(require("@microsoft/signalr")); -var MessagePackHubProtocol; -async function loadMessagePackProtocol() { - if (!MessagePackHubProtocol) { - try { - const msgpack = await import("@microsoft/signalr-protocol-msgpack"); - MessagePackHubProtocol = msgpack.MessagePackHubProtocol; - return msgpack.MessagePackHubProtocol; - } catch (error) { - console.warn("MessagePack protocol not available, using JSON:", error); - return null; - } - } - return MessagePackHubProtocol; -} -var BaseSignalRConnection = class { - connection; - config; - connectionReadyPromise; - connectionReadyResolve; - connectionReadyReject; - disposed = false; - constructor(config) { - this.config = { - ...config, - baseUrl: config.baseUrl.replace(/\/$/, "") - }; - this.connectionReadyPromise = new Promise((resolve, reject) => { - this.connectionReadyResolve = resolve; - this.connectionReadyReject = reject; - }); - } - /** - * Gets whether the connection is established and ready for use. - */ - get isConnected() { - return this.connection?.state === signalR.HubConnectionState.Connected; - } - /** - * Gets the current connection state. - */ - get state() { - if (!this.connection) { - return "Disconnected" /* Disconnected */; - } - switch (this.connection.state) { - case signalR.HubConnectionState.Connected: - return "Connected" /* Connected */; - case signalR.HubConnectionState.Connecting: - return "Connecting" /* Connecting */; - case signalR.HubConnectionState.Disconnected: - return "Disconnected" /* Disconnected */; - case signalR.HubConnectionState.Disconnecting: - return "Disconnecting" /* Disconnecting */; - case signalR.HubConnectionState.Reconnecting: - return "Reconnecting" /* Reconnecting */; - default: - return "Disconnected" /* Disconnected */; - } - } - /** - * Event handlers - */ - onConnected; - onDisconnected; - onReconnecting; - onReconnected; - /** - * Establishes the SignalR connection. - */ - async getConnection() { - if (this.connection) { - return this.connection; - } - const hubUrl = `${this.config.baseUrl}${this.hubPath}`; - const connectionOptions = { - accessTokenFactory: this.config.options?.accessTokenFactory || (() => this.config.auth.authToken), - transport: this.mapTransportType(this.config.options?.transport || DefaultTransports), - headers: this.buildHeaders(), - withCredentials: false - }; - const builder = new signalR.HubConnectionBuilder().withUrl(hubUrl, connectionOptions).withAutomaticReconnect(this.config.options?.reconnectionDelay || [0, 2e3, 1e4, 3e4]); - if (this.config.options?.serverTimeout) { - builder.withServerTimeout(this.config.options.serverTimeout); - } - if (this.config.options?.keepAliveInterval) { - builder.withKeepAliveInterval(this.config.options.keepAliveInterval); - } - const logLevel = this.mapLogLevel(this.config.options?.logLevel || 2 /* Information */); - builder.configureLogging(logLevel); - const protocolType = this.config.options?.protocol || "json" /* Json */; - if (protocolType === "messagepack" /* MessagePack */) { - try { - const MessagePackProtocol = await loadMessagePackProtocol(); - if (MessagePackProtocol) { - builder.withHubProtocol(new MessagePackProtocol()); - console.warn("Using MessagePack protocol for SignalR connection"); - } - } catch (error) { - console.error("Failed to load MessagePack protocol, falling back to JSON:", error); - } - } - this.connection = builder.build(); - this.connection.onclose(async (error) => { - if (this.onDisconnected) { - await this.onDisconnected(error); - } - }); - this.connection.onreconnecting(async (error) => { - if (this.onReconnecting) { - await this.onReconnecting(error); - } - }); - this.connection.onreconnected(async (connectionId) => { - if (this.onReconnected) { - await this.onReconnected(connectionId); - } - }); - this.configureHubHandlers(this.connection); - try { - await this.connection.start(); - if (this.connectionReadyResolve) { - this.connectionReadyResolve(); - } - if (this.onConnected) { - await this.onConnected(); - } - } catch (error) { - if (this.connectionReadyReject) { - this.connectionReadyReject(error); - } - throw error; - } - return this.connection; - } - /** - * Maps transport type enum to SignalR transport. - */ - mapTransportType(transport) { - let result = signalR.HttpTransportType.None; - if (transport & 1 /* WebSockets */) { - result |= signalR.HttpTransportType.WebSockets; - } - if (transport & 2 /* ServerSentEvents */) { - result |= signalR.HttpTransportType.ServerSentEvents; - } - if (transport & 4 /* LongPolling */) { - result |= signalR.HttpTransportType.LongPolling; - } - return result; - } - /** - * Maps log level enum to SignalR log level. - */ - mapLogLevel(level) { - switch (level) { - case 0 /* Trace */: - return signalR.LogLevel.Trace; - case 1 /* Debug */: - return signalR.LogLevel.Debug; - case 2 /* Information */: - return signalR.LogLevel.Information; - case 3 /* Warning */: - return signalR.LogLevel.Warning; - case 4 /* Error */: - return signalR.LogLevel.Error; - case 5 /* Critical */: - return signalR.LogLevel.Critical; - case 6 /* None */: - return signalR.LogLevel.None; - default: - return signalR.LogLevel.Information; - } - } - /** - * Builds headers for the connection based on configuration. - */ - buildHeaders() { - const headers = { - "User-Agent": this.config.userAgent || "Conduit-Node-Client/1.0.0", - ...this.config.options?.headers - }; - if (this.config.auth.authType === "master" && this.config.auth.additionalHeaders) { - Object.assign(headers, this.config.auth.additionalHeaders); - } - return headers; - } - /** - * Waits for the connection to be ready. - */ - async waitForReady() { - return this.connectionReadyPromise; - } - /** - * Invokes a method on the hub with proper error handling. - */ - async invoke(methodName, ...args) { - if (this.disposed) { - throw new Error("Connection has been disposed"); - } - const connection = await this.getConnection(); - try { - return await connection.invoke(methodName, ...args); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`SignalR invoke error for ${methodName}: ${errorMessage}`); - } - } - /** - * Sends a message to the hub without expecting a response. - */ - async send(methodName, ...args) { - if (this.disposed) { - throw new Error("Connection has been disposed"); - } - const connection = await this.getConnection(); - try { - await connection.send(methodName, ...args); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`SignalR send error for ${methodName}: ${errorMessage}`); - } - } - /** - * Disconnects the SignalR connection. - */ - async disconnect() { - if (this.connection && this.connection.state !== signalR.HubConnectionState.Disconnected) { - await this.connection.stop(); - this.connection = void 0; - this.connectionReadyPromise = new Promise((resolve, reject) => { - this.connectionReadyResolve = resolve; - this.connectionReadyReject = reject; - }); - } - } - /** - * Disposes of the connection and cleans up resources. - */ - async dispose() { - this.disposed = true; - await this.disconnect(); - this.connectionReadyResolve = void 0; - this.connectionReadyReject = void 0; - } -}; - -// src/client/types.ts -var HttpError = class extends Error { - code; - response; - request; - config; - constructor(message, code) { - super(message); - this.name = "HttpError"; - this.code = code; - } -}; - -// src/client/retry-strategy.ts -var RetryStrategyType = /* @__PURE__ */ ((RetryStrategyType2) => { - RetryStrategyType2["FIXED_DELAY"] = "fixed_delay"; - RetryStrategyType2["EXPONENTIAL_BACKOFF"] = "exponential_backoff"; - RetryStrategyType2["CUSTOM_DELAYS"] = "custom_delays"; - return RetryStrategyType2; -})(RetryStrategyType || {}); -function calculateRetryDelay(strategy, attempt) { - switch (strategy.type) { - case "fixed_delay" /* FIXED_DELAY */: - return strategy.delayMs; - case "exponential_backoff" /* EXPONENTIAL_BACKOFF */: { - const delay = Math.min( - strategy.initialDelayMs * Math.pow(strategy.factor, attempt - 1), - strategy.maxDelayMs - ); - if (strategy.jitter) { - return delay + Math.random() * 1e3; - } - return delay; - } - case "custom_delays" /* CUSTOM_DELAYS */: { - const index = Math.min(attempt - 1, strategy.delays.length - 1); - return strategy.delays[index]; - } - } -} -function getMaxRetries(strategy) { - switch (strategy.type) { - case "fixed_delay" /* FIXED_DELAY */: - case "exponential_backoff" /* EXPONENTIAL_BACKOFF */: - return strategy.maxRetries; - case "custom_delays" /* CUSTOM_DELAYS */: - return strategy.delays.length; - } -} -function shouldRetryWithStrategy(strategy, error) { - if (strategy.retryCondition) { - return strategy.retryCondition(error); - } - return false; -} -var DEFAULT_RETRY_STRATEGIES = { - /** Gateway SDK default: exponential backoff with jitter */ - gateway: { - type: "exponential_backoff" /* EXPONENTIAL_BACKOFF */, - maxRetries: 3, - initialDelayMs: 1e3, - maxDelayMs: 3e4, - factor: 2, - jitter: true - }, - /** Admin SDK default: fixed delay */ - admin: { - type: "fixed_delay" /* FIXED_DELAY */, - maxRetries: 3, - delayMs: 1e3 - } -}; - -// src/client/BaseApiClient.ts -var BaseApiClient = class { - /** Base URL for all requests (without trailing slash) */ - baseUrl; - /** Default timeout in milliseconds */ - timeout; - /** Default headers included with all requests */ - defaultHeaders; - /** Retry strategy configuration */ - retryStrategy; - /** Enable debug logging */ - debug; - // Lifecycle callbacks - onError; - onRequest; - onResponse; - // Optional providers (Admin SDK uses these, Gateway SDK may not) - logger; - cache; - constructor(config) { - this.baseUrl = config.baseUrl.replace(/\/$/, ""); - this.timeout = config.timeout ?? 6e4; - this.defaultHeaders = config.defaultHeaders ?? {}; - this.retryStrategy = config.retryStrategy ?? this.getDefaultRetryStrategy(); - this.debug = config.debug ?? false; - this.onError = config.onError; - this.onRequest = config.onRequest; - this.onResponse = config.onResponse; - this.logger = config.logger; - this.cache = config.cache; - } - // ============================================================================ - // Template Methods - Can be overridden by SDK-specific clients - // ============================================================================ - /** - * Transform error response into appropriate error type - * Subclasses can override for SDK-specific error handling - * - * @param response - The failed Response object - * @returns An Error to throw - */ - async handleErrorResponse(response) { - let errorData; - try { - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - errorData = await response.json(); - } - } catch { - errorData = {}; - } - return new ConduitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status, - `HTTP_${response.status}`, - { data: errorData } - ); - } - /** - * Determine if an error should be retried - * Subclasses can override for SDK-specific retry logic - * - * @param error - The error that occurred - * @param attempt - Current attempt number (1-based) - * @returns Whether to retry the request - */ - shouldRetry(error, attempt) { - const maxRetries = getMaxRetries(this.retryStrategy); - if (attempt > maxRetries) return false; - if (this.retryStrategy.retryCondition) { - return this.retryStrategy.retryCondition(error); - } - if (error instanceof ConduitError) { - return error.statusCode === 429 || error.statusCode >= 500; - } - if (error instanceof Error) { - return error.name === "AbortError" || error.message.includes("network") || error.message.includes("fetch"); - } - return false; - } - /** - * Calculate delay for a retry attempt - * Subclasses can override for special cases (e.g., retry-after headers) - * - * @param error - The error that triggered the retry - * @param attempt - Current attempt number (1-based) - * @returns Delay in milliseconds before next retry - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getRetryDelay(_error, attempt) { - return calculateRetryDelay(this.retryStrategy, attempt); - } - // ============================================================================ - // HTTP Methods - // ============================================================================ - /** - * Main request method with retry logic - */ - async request(url, options = {}) { - const fullUrl = this.buildUrl(url); - const controller = new AbortController(); - const timeoutMs = options.timeout ?? this.timeout; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - try { - const requestInfo = { - method: options.method ?? "GET" /* GET */, - url: fullUrl, - headers: this.buildHeaders(options.headers), - data: options.body - }; - if (this.onRequest) { - await this.onRequest(requestInfo); - } - this.log("debug", `API Request: ${requestInfo.method} ${requestInfo.url}`); - const response = await this.executeWithRetry( - fullUrl, - { - method: requestInfo.method, - headers: requestInfo.headers, - body: options.body ? JSON.stringify(options.body) : void 0, - signal: options.signal ?? controller.signal, - responseType: options.responseType, - timeout: timeoutMs - } - ); - return response; - } finally { - clearTimeout(timeoutId); - } - } - /** - * Type-safe GET request - */ - async get(url, options) { - return this.request(url, { ...options, method: "GET" /* GET */ }); - } - /** - * Type-safe POST request - */ - async post(url, data, options) { - return this.request(url, { - ...options, - method: "POST" /* POST */, - body: data - }); - } - /** - * Type-safe PUT request - */ - async put(url, data, options) { - return this.request(url, { - ...options, - method: "PUT" /* PUT */, - body: data - }); - } - /** - * Type-safe PATCH request - */ - async patch(url, data, options) { - return this.request(url, { - ...options, - method: "PATCH" /* PATCH */, - body: data - }); - } - /** - * Type-safe DELETE request - */ - async delete(url, options) { - return this.request(url, { ...options, method: "DELETE" /* DELETE */ }); - } - // ============================================================================ - // Internal Methods - // ============================================================================ - /** - * Execute request with retry logic - */ - async executeWithRetry(url, init, attempt = 1) { - try { - const response = await fetch(url, ResponseParser.cleanRequestInit(init)); - this.log("debug", `API Response: ${response.status} ${response.statusText}`); - const headers = {}; - response.headers.forEach((value, key) => { - headers[key] = value; - }); - if (this.onResponse) { - const responseInfo = { - status: response.status, - statusText: response.statusText, - headers, - data: void 0, - config: { - url, - method: init.method ?? "GET" /* GET */, - headers: init.headers ?? {} - } - }; - await this.onResponse(responseInfo); - } - if (!response.ok) { - const error = await this.handleErrorResponse(response); - throw error; - } - const contentLength = response.headers.get("content-length"); - if (contentLength === "0" || response.status === 204) { - return void 0; - } - return await ResponseParser.parse(response, init.responseType); - } catch (error) { - if (this.shouldRetry(error, attempt)) { - const delay = this.getRetryDelay(error, attempt); - this.log("debug", `Retrying request (attempt ${attempt + 1}) after ${delay}ms`); - await this.sleep(delay); - return this.executeWithRetry(url, init, attempt + 1); - } - if (this.onError && error instanceof Error) { - this.onError(error); - } - throw error; - } - } - /** - * Build full URL from path - */ - buildUrl(path) { - if (path.startsWith("http://") || path.startsWith("https://")) { - return path; - } - const cleanPath = path.startsWith("/") ? path : `/${path}`; - return `${this.baseUrl}${cleanPath}`; - } - /** - * Build headers including auth, defaults, and additional headers - */ - buildHeaders(additionalHeaders) { - return { - [HTTP_HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON, - ...this.getAuthHeaders(), - ...this.defaultHeaders, - ...additionalHeaders - }; - } - /** - * Log a message using the configured logger or console in debug mode - */ - log(level, message, ...args) { - if (this.logger?.[level]) { - this.logger[level](message, ...args); - } else if (this.debug && level === "debug") { - console.warn(`[SDK] ${message}`, ...args); - } - } - /** - * Sleep for a specified duration - */ - sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - // ============================================================================ - // Caching Utilities (Optional - only active if cache provider is configured) - // ============================================================================ - /** - * Get a value from cache - * Returns null if cache is not configured or key is not found - */ - async getFromCache(key) { - if (!this.cache) return null; - try { - const cached = await this.cache.get(key); - if (cached) { - this.log("debug", `Cache hit for key: ${key}`); - return cached; - } - } catch (error) { - this.log("error", "Cache get error:", error); - } - return null; - } - /** - * Set a value in cache - * No-op if cache is not configured - */ - async setCache(key, value, ttl) { - if (!this.cache) return; - try { - await this.cache.set(key, value, ttl); - this.log("debug", `Cache set for key: ${key}`); - } catch (error) { - this.log("error", "Cache set error:", error); - } - } - /** - * Execute a function with caching - * Returns cached value if available, otherwise executes function and caches result - */ - async withCache(cacheKey, fn, ttl) { - const cached = await this.getFromCache(cacheKey); - if (cached !== null) { - return cached; - } - const result = await fn(); - await this.setCache(cacheKey, result, ttl); - return result; - } - /** - * Generate a cache key from resource and identifiers - */ - getCacheKey(resource, ...identifiers) { - const parts = identifiers.filter((id) => id !== void 0).map((id) => typeof id === "object" ? JSON.stringify(id) : String(id)); - return `${resource}:${parts.join(":")}`; - } -}; - -// src/circuit-breaker/types.ts -var CircuitState = /* @__PURE__ */ ((CircuitState2) => { - CircuitState2["CLOSED"] = "closed"; - CircuitState2["OPEN"] = "open"; - CircuitState2["HALF_OPEN"] = "half_open"; - return CircuitState2; -})(CircuitState || {}); - -// src/circuit-breaker/errors.ts -var CircuitBreakerOpenError = class extends ConduitError { - /** Current circuit breaker state */ - circuitState; - /** Time until circuit transitions to HALF_OPEN (milliseconds) */ - timeUntilHalfOpen; - /** Circuit breaker statistics at time of rejection */ - stats; - constructor(message, stats, timeUntilHalfOpen) { - super(message, 503, "CIRCUIT_BREAKER_OPEN", { - circuitState: stats.state, - timeUntilHalfOpen, - consecutiveFailures: stats.consecutiveFailures, - totalFailures: stats.totalFailures - }); - this.circuitState = stats.state; - this.timeUntilHalfOpen = timeUntilHalfOpen; - this.stats = stats; - } -}; -function isCircuitBreakerOpenError(error) { - return error instanceof CircuitBreakerOpenError; -} - -// src/circuit-breaker/CircuitBreaker.ts -var DEFAULT_CONFIG = { - failureThreshold: 3, - failureWindowMs: 6e4, - // 60 seconds - resetTimeoutMs: 3e4, - // 30 seconds - successThreshold: 1, - enableLogging: false -}; -var CircuitBreaker = class { - config; - callbacks; - // State tracking - state = "closed" /* CLOSED */; - failures = []; - halfOpenSuccesses = 0; - // Statistics - totalFailures = 0; - totalSuccesses = 0; - rejectedRequests = 0; - circuitOpenedAt = null; - lastFailureAt = null; - lastSuccessAt = null; - constructor(config = {}, callbacks = {}) { - this.config = { - ...DEFAULT_CONFIG, - ...config - }; - this.callbacks = callbacks; - } - /** - * Get current state of the circuit - * Automatically transitions OPEN -> HALF_OPEN after timeout - */ - getState() { - if (this.state === "open" /* OPEN */ && this.circuitOpenedAt !== null) { - const elapsed = Date.now() - this.circuitOpenedAt; - if (elapsed >= this.config.resetTimeoutMs) { - this.transitionTo("half_open" /* HALF_OPEN */); - } - } - return this.state; - } - /** - * Get circuit breaker statistics - */ - getStats() { - const currentState = this.getState(); - return { - state: currentState, - consecutiveFailures: this.getConsecutiveFailuresInWindow(), - totalFailures: this.totalFailures, - totalSuccesses: this.totalSuccesses, - circuitOpenedAt: this.circuitOpenedAt, - timeUntilHalfOpen: this.calculateTimeUntilHalfOpen(), - lastFailureAt: this.lastFailureAt, - lastSuccessAt: this.lastSuccessAt, - rejectedRequests: this.rejectedRequests - }; - } - /** - * Check if a request can proceed - * Returns true if circuit is CLOSED or HALF_OPEN - */ - canExecute() { - const state = this.getState(); - return state !== "open" /* OPEN */; - } - /** - * Check if request should proceed, throwing if circuit is open - * @throws CircuitBreakerOpenError if circuit is OPEN - */ - checkOpen() { - const state = this.getState(); - if (state === "open" /* OPEN */) { - this.rejectedRequests++; - const stats = this.getStats(); - this.callbacks.onRejected?.(stats); - throw new CircuitBreakerOpenError( - `Circuit breaker is open. Try again in ${Math.ceil((stats.timeUntilHalfOpen ?? 0) / 1e3)} seconds.`, - stats, - stats.timeUntilHalfOpen - ); - } - } - /** - * Record a successful request - */ - recordSuccess() { - this.totalSuccesses++; - this.lastSuccessAt = Date.now(); - const currentState = this.getState(); - if (currentState === "half_open" /* HALF_OPEN */) { - this.halfOpenSuccesses++; - this.log("debug", `Half-open success ${this.halfOpenSuccesses}/${this.config.successThreshold}`); - if (this.halfOpenSuccesses >= this.config.successThreshold) { - this.transitionTo("closed" /* CLOSED */); - } - } else if (currentState === "closed" /* CLOSED */) { - this.failures = []; - } - } - /** - * Record a failed request - */ - recordFailure(error) { - if (this.config.shouldCountAsFailure && !this.config.shouldCountAsFailure(error)) { - this.log("debug", "Error not counted as failure by custom filter"); - return; - } - const now = Date.now(); - this.totalFailures++; - this.lastFailureAt = now; - const currentState = this.getState(); - if (currentState === "half_open" /* HALF_OPEN */) { - this.log("warn", "Failure in half-open state, reopening circuit"); - this.transitionTo("open" /* OPEN */, error); - return; - } - if (currentState === "closed" /* CLOSED */) { - this.failures.push({ timestamp: now, error }); - this.pruneOldFailures(); - const consecutiveFailures = this.getConsecutiveFailuresInWindow(); - this.log("debug", `Consecutive failures: ${consecutiveFailures}/${this.config.failureThreshold}`); - if (consecutiveFailures >= this.config.failureThreshold) { - this.transitionTo("open" /* OPEN */, error); - } - } - } - /** - * Manually reset the circuit to CLOSED state - * Use with caution - typically for testing or admin override - */ - reset() { - this.log("info", "Circuit manually reset"); - this.transitionTo("closed" /* CLOSED */); - this.failures = []; - this.totalFailures = 0; - this.totalSuccesses = 0; - this.rejectedRequests = 0; - } - // Private methods - transitionTo(newState, triggerError) { - const oldState = this.state; - if (oldState === newState) return; - this.state = newState; - const stats = this.getStats(); - this.log("info", `Circuit state change: ${oldState} -> ${newState}`); - switch (newState) { - case "open" /* OPEN */: - this.circuitOpenedAt = Date.now(); - this.halfOpenSuccesses = 0; - this.callbacks.onOpen?.(stats, triggerError); - break; - case "half_open" /* HALF_OPEN */: - this.halfOpenSuccesses = 0; - this.callbacks.onHalfOpen?.(stats); - break; - case "closed" /* CLOSED */: - this.circuitOpenedAt = null; - this.failures = []; - this.halfOpenSuccesses = 0; - this.callbacks.onClose?.(stats); - break; - } - this.callbacks.onStateChange?.(oldState, newState, stats); - } - pruneOldFailures() { - const cutoff = Date.now() - this.config.failureWindowMs; - this.failures = this.failures.filter((f) => f.timestamp >= cutoff); - } - getConsecutiveFailuresInWindow() { - this.pruneOldFailures(); - return this.failures.length; - } - calculateTimeUntilHalfOpen() { - if (this.state !== "open" /* OPEN */ || this.circuitOpenedAt === null) { - return null; - } - const elapsed = Date.now() - this.circuitOpenedAt; - const remaining = this.config.resetTimeoutMs - elapsed; - return remaining > 0 ? remaining : 0; - } - log(_level, message) { - if (this.config.enableLogging) { - console.warn(`[CircuitBreaker] ${message}`); - } - } -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - AuthError, - AuthenticationError, - AuthorizationError, - BaseApiClient, - BaseSignalRConnection, - CONTENT_TYPES, - CircuitBreaker, - CircuitBreakerOpenError, - CircuitState, - ConduitError, - ConflictError, - DEFAULT_RETRY_STRATEGIES, - DefaultTransports, - ERROR_CODES, - HTTP_HEADERS, - HTTP_STATUS, - HttpError, - HttpMethod, - HttpTransportType, - HubConnectionState, - InsufficientBalanceError, - ModelCapability, - NetworkError, - NotFoundError, - NotImplementedError, - RETRY_CONFIG, - RateLimitError, - ResponseParser, - RetryStrategyType, - ServerError, - SignalRLogLevel, - SignalRProtocolType, - StreamError, - TIMEOUTS, - TimeoutError, - ValidationError, - calculateRetryDelay, - createErrorFromResponse, - deserializeError, - getCapabilityCategory, - getCapabilityDisplayName, - getErrorMessage, - getErrorStatusCode, - getMaxRetries, - handleApiError, - isAuthError, - isAuthorizationError, - isCircuitBreakerOpenError, - isConduitError, - isConflictError, - isErrorLike, - isHttpError, - isHttpMethod, - isHttpNetworkError, - isInsufficientBalanceError, - isNetworkError, - isNotFoundError, - isRateLimitError, - isSerializedConduitError, - isServerError, - isStreamError, - isTimeoutError, - isValidationError, - serializeError, - shouldRetryWithStrategy -}); -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/SDKs/Node/Common/dist/index.js.map b/SDKs/Node/Common/dist/index.js.map deleted file mode 100644 index 6f045a7e9..000000000 --- a/SDKs/Node/Common/dist/index.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["../src/index.ts","../src/types/capabilities.ts","../src/errors/index.ts","../src/http/types.ts","../src/http/parser.ts","../src/http/constants.ts","../src/signalr/types.ts","../src/signalr/BaseSignalRConnection.ts","../src/client/types.ts","../src/client/retry-strategy.ts","../src/client/BaseApiClient.ts","../src/circuit-breaker/types.ts","../src/circuit-breaker/errors.ts","../src/circuit-breaker/CircuitBreaker.ts"],"sourcesContent":["/**\n * @knn_labs/conduit-common - Shared types for Conduit SDK clients\n */\n\n// Base types\nexport * from './types/base';\n\n// Pagination types\nexport * from './types/pagination';\n\n// Capability types\nexport * from './types/capabilities';\n\n// Error types and utilities\nexport * from './errors';\n\n// HTTP types and utilities\nexport * from './http';\n\n// SignalR types and base classes\nexport * from './signalr';\n\n// Client configuration types\nexport * from './client';\n\n// Explicit exports for types that might get tree-shaken\nexport type { Logger, CacheProvider, RequestConfigInfo, ResponseInfo } from './client/types';\nexport { HttpError } from './client/types';\nexport type { SignalRConfig } from './client/signalr-config';\nexport type { SignalRConnectionOptions } from './signalr/types';\n\n// Explicit exports for BaseApiClient (may get tree-shaken)\nexport { BaseApiClient } from './client/BaseApiClient';\nexport type { BaseRequestOptions } from './client/BaseApiClient';\nexport type {\n BaseApiClientConfig,\n CacheableClientConfig,\n LoggableClientConfig,\n FullFeaturedClientConfig\n} from './client/base-client-config';\nexport {\n RetryStrategyType,\n calculateRetryDelay,\n getMaxRetries,\n shouldRetryWithStrategy,\n DEFAULT_RETRY_STRATEGIES\n} from './client/retry-strategy';\nexport type {\n RetryStrategy,\n FixedDelayConfig,\n ExponentialBackoffConfig,\n CustomDelaysConfig\n} from './client/retry-strategy';\n\n// Circuit breaker types and classes\nexport {\n CircuitState,\n CircuitBreaker,\n CircuitBreakerOpenError,\n isCircuitBreakerOpenError\n} from './circuit-breaker';\nexport type {\n CircuitBreakerConfig,\n CircuitBreakerStats,\n CircuitBreakerCallbacks\n} from './circuit-breaker';","/**\n * Model capability definitions shared across Conduit SDK clients\n */\n\n/**\n * Core model capabilities supported by Conduit\n */\nexport enum ModelCapability {\n CHAT = 'chat',\n VISION = 'vision',\n IMAGE_GENERATION = 'image-generation',\n IMAGE_EDIT = 'image-edit',\n IMAGE_VARIATION = 'image-variation',\n AUDIO_TRANSCRIPTION = 'audio-transcription',\n TEXT_TO_SPEECH = 'text-to-speech',\n REALTIME_AUDIO = 'realtime-audio',\n EMBEDDINGS = 'embeddings',\n VIDEO_GENERATION = 'video-generation',\n}\n\n/**\n * Model capability metadata\n */\nexport interface ModelCapabilityInfo {\n id: ModelCapability;\n displayName: string;\n description?: string;\n category: 'text' | 'vision' | 'audio' | 'video';\n}\n\n/**\n * Model capabilities definition for a specific model\n */\nexport interface ModelCapabilities {\n modelId: string;\n capabilities: ModelCapability[];\n constraints?: ModelConstraints;\n}\n\n/**\n * Model-specific constraints\n */\nexport interface ModelConstraints {\n maxTokens?: number;\n maxImages?: number;\n supportedImageSizes?: string[];\n supportedImageFormats?: string[];\n supportedAudioFormats?: string[];\n supportedVideoSizes?: string[];\n supportedLanguages?: string[];\n supportedVoices?: string[];\n maxDuration?: number;\n}\n\n/**\n * Get user-friendly display name for a capability\n */\nexport function getCapabilityDisplayName(capability: ModelCapability): string {\n switch (capability) {\n case ModelCapability.CHAT:\n return 'Chat Completion';\n case ModelCapability.VISION:\n return 'Vision (Image Understanding)';\n case ModelCapability.IMAGE_GENERATION:\n return 'Image Generation';\n case ModelCapability.IMAGE_EDIT:\n return 'Image Editing';\n case ModelCapability.IMAGE_VARIATION:\n return 'Image Variation';\n case ModelCapability.AUDIO_TRANSCRIPTION:\n return 'Audio Transcription';\n case ModelCapability.TEXT_TO_SPEECH:\n return 'Text-to-Speech';\n case ModelCapability.REALTIME_AUDIO:\n return 'Realtime Audio';\n case ModelCapability.EMBEDDINGS:\n return 'Embeddings';\n case ModelCapability.VIDEO_GENERATION:\n return 'Video Generation';\n default:\n return capability;\n }\n}\n\n/**\n * Get capability category\n */\nexport function getCapabilityCategory(capability: ModelCapability): 'text' | 'vision' | 'audio' | 'video' {\n switch (capability) {\n case ModelCapability.CHAT:\n case ModelCapability.EMBEDDINGS:\n return 'text';\n case ModelCapability.VISION:\n case ModelCapability.IMAGE_GENERATION:\n case ModelCapability.IMAGE_EDIT:\n case ModelCapability.IMAGE_VARIATION:\n return 'vision';\n case ModelCapability.AUDIO_TRANSCRIPTION:\n case ModelCapability.TEXT_TO_SPEECH:\n case ModelCapability.REALTIME_AUDIO:\n return 'audio';\n case ModelCapability.VIDEO_GENERATION:\n return 'video';\n default:\n return 'text';\n }\n}","/**\n * Common error types for Conduit SDK clients\n * \n * This module provides a unified error hierarchy for both Admin and Core SDKs,\n * consolidating previously duplicated error classes.\n */\n\nexport class ConduitError extends Error {\n public statusCode: number;\n public code: string;\n public context?: Record;\n \n // Admin SDK specific fields\n public details?: unknown;\n public endpoint?: string;\n public method?: string;\n \n // Core SDK specific fields\n public type?: string;\n public param?: string;\n\n constructor(\n message: string,\n statusCode: number = 500,\n code: string = 'INTERNAL_ERROR',\n context?: Record\n ) {\n super(message);\n this.name = this.constructor.name;\n this.statusCode = statusCode;\n this.code = code;\n this.context = context;\n \n // Preserve additional context from the constructor pattern\n if (context) {\n // Admin SDK fields\n this.details = context.details;\n this.endpoint = context.endpoint as string | undefined;\n this.method = context.method as string | undefined;\n \n // Core SDK fields\n this.type = context.type as string | undefined;\n this.param = context.param as string | undefined;\n }\n \n // Ensure proper prototype chain for instanceof checks\n Object.setPrototypeOf(this, new.target.prototype);\n \n // Capture stack trace for better debugging\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, this.constructor);\n }\n }\n\n toJSON() {\n return {\n name: this.name,\n message: this.message,\n statusCode: this.statusCode,\n code: this.code,\n context: this.context,\n details: this.details,\n endpoint: this.endpoint,\n method: this.method,\n type: this.type,\n param: this.param,\n timestamp: new Date().toISOString(),\n };\n }\n \n // Helper method for Next.js serialization\n toSerializable() {\n return {\n isConduitError: true,\n ...this.toJSON(),\n };\n }\n \n // Static method to reconstruct from serialized error\n static fromSerializable(data: unknown): ConduitError {\n if (!data || typeof data !== 'object' || !('isConduitError' in data) || !(data as { isConduitError: unknown }).isConduitError) {\n throw new Error('Invalid serialized ConduitError');\n }\n \n const errorData = data as unknown as {\n message: string;\n statusCode: number;\n code: string;\n context?: Record;\n details?: unknown;\n endpoint?: string;\n method?: string;\n type?: string;\n param?: string;\n };\n \n const error = new ConduitError(\n errorData.message,\n errorData.statusCode,\n errorData.code,\n errorData.context\n );\n \n // Restore additional properties\n if (errorData.details !== undefined) error.details = errorData.details;\n if (errorData.endpoint !== undefined) error.endpoint = errorData.endpoint;\n if (errorData.method !== undefined) error.method = errorData.method;\n if (errorData.type !== undefined) error.type = errorData.type;\n if (errorData.param !== undefined) error.param = errorData.param;\n \n return error;\n }\n}\n\nexport class AuthError extends ConduitError {\n constructor(message = 'Authentication failed', context?: Record) {\n super(message, 401, 'AUTH_ERROR', context);\n }\n}\n\n// Alias for backward compatibility\nexport class AuthenticationError extends AuthError {}\n\nexport class AuthorizationError extends ConduitError {\n constructor(message = 'Access forbidden', context?: Record) {\n super(message, 403, 'AUTHORIZATION_ERROR', context);\n }\n}\n\nexport class ValidationError extends ConduitError {\n public field?: string;\n \n constructor(message = 'Validation failed', context?: Record) {\n super(message, 400, 'VALIDATION_ERROR', context);\n this.field = context?.field as string | undefined;\n }\n}\n\nexport class NotFoundError extends ConduitError {\n constructor(message = 'Resource not found', context?: Record) {\n super(message, 404, 'NOT_FOUND', context);\n }\n}\n\nexport class ConflictError extends ConduitError {\n constructor(message = 'Resource conflict', context?: Record) {\n super(message, 409, 'CONFLICT_ERROR', context);\n }\n}\n\nexport class InsufficientBalanceError extends ConduitError {\n public balance?: number;\n public requiredAmount?: number;\n\n constructor(message = 'Insufficient balance to complete request', context?: Record) {\n super(message, 402, 'INSUFFICIENT_BALANCE', context);\n this.balance = context?.balance as number | undefined;\n this.requiredAmount = context?.requiredAmount as number | undefined;\n }\n}\n\nexport class RateLimitError extends ConduitError {\n public retryAfter?: number;\n\n constructor(message = 'Rate limit exceeded', retryAfter?: number, context?: Record) {\n super(message, 429, 'RATE_LIMIT_ERROR', { ...context, retryAfter });\n this.retryAfter = retryAfter;\n }\n}\n\nexport class ServerError extends ConduitError {\n constructor(message = 'Internal server error', context?: Record) {\n super(message, 500, 'SERVER_ERROR', context);\n }\n}\n\nexport class NetworkError extends ConduitError {\n constructor(message = 'Network error', context?: Record) {\n super(message, 0, 'NETWORK_ERROR', context);\n }\n}\n\nexport class TimeoutError extends ConduitError {\n constructor(message = 'Request timeout', context?: Record) {\n super(message, 408, 'TIMEOUT_ERROR', context);\n }\n}\n\nexport class NotImplementedError extends ConduitError {\n constructor(message: string, context?: Record) {\n super(message, 501, 'NOT_IMPLEMENTED', context);\n }\n}\n\nexport class StreamError extends ConduitError {\n constructor(message = 'Stream processing failed', context?: Record) {\n super(message, 500, 'STREAM_ERROR', context);\n }\n}\n\n// Type guards\nexport function isConduitError(error: unknown): error is ConduitError {\n return error instanceof ConduitError;\n}\n\nexport function isAuthError(error: unknown): error is AuthError {\n return error instanceof AuthError || error instanceof AuthenticationError;\n}\n\nexport function isAuthorizationError(error: unknown): error is AuthorizationError {\n return error instanceof AuthorizationError;\n}\n\nexport function isValidationError(error: unknown): error is ValidationError {\n return error instanceof ValidationError;\n}\n\nexport function isNotFoundError(error: unknown): error is NotFoundError {\n return error instanceof NotFoundError;\n}\n\nexport function isConflictError(error: unknown): error is ConflictError {\n return error instanceof ConflictError;\n}\n\nexport function isInsufficientBalanceError(error: unknown): error is InsufficientBalanceError {\n return error instanceof InsufficientBalanceError;\n}\n\nexport function isRateLimitError(error: unknown): error is RateLimitError {\n return error instanceof RateLimitError;\n}\n\nexport function isNetworkError(error: unknown): error is NetworkError {\n return error instanceof NetworkError;\n}\n\nexport function isStreamError(error: unknown): error is StreamError {\n return error instanceof StreamError;\n}\n\nexport function isTimeoutError(error: unknown): error is TimeoutError {\n return error instanceof TimeoutError;\n}\n\nexport function isServerError(error: unknown): error is ConduitError {\n return isConduitError(error) &&\n error.statusCode !== undefined &&\n error.statusCode >= 500;\n}\n\n// Helper to check if an error is serialized ConduitError\nexport function isSerializedConduitError(data: unknown): data is ReturnType {\n return (\n typeof data === 'object' &&\n data !== null &&\n 'isConduitError' in data &&\n (data as { isConduitError: unknown }).isConduitError === true\n );\n}\n\n// Type guard for HTTP errors\nexport function isHttpError(error: unknown): error is {\n response: { status: number; data: unknown; headers: Record };\n message: string;\n request?: unknown;\n code?: string;\n} {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'response' in error &&\n typeof (error as { response: unknown }).response === 'object'\n );\n}\n\n// Type guard for network errors\nexport function isHttpNetworkError(error: unknown): error is {\n request: unknown;\n message: string;\n code?: string;\n} {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'request' in error &&\n !('response' in error)\n );\n}\n\n// Type guard for generic errors\nexport function isErrorLike(error: unknown): error is {\n message: string;\n} {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'message' in error &&\n typeof (error as { message: unknown }).message === 'string'\n );\n}\n\n// Next.js-specific utilities for error serialization across server/client boundaries\nexport function serializeError(error: unknown): Record {\n if (isConduitError(error)) {\n return error.toSerializable();\n }\n \n if (error instanceof Error) {\n return {\n isError: true,\n name: error.name,\n message: error.message,\n stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,\n };\n }\n \n return {\n isError: true,\n message: String(error),\n };\n}\n\nexport function deserializeError(data: unknown): Error {\n if (isSerializedConduitError(data)) {\n return ConduitError.fromSerializable(data);\n }\n \n if (typeof data === 'object' && data !== null && 'isError' in data) {\n const errorData = data as {\n message?: string;\n name?: string;\n stack?: string;\n isError: boolean;\n };\n const error = new Error(errorData.message || 'Unknown error');\n if (errorData.name) error.name = errorData.name;\n if (errorData.stack) error.stack = errorData.stack;\n return error;\n }\n \n return new Error('Unknown error');\n}\n\n// Helper for Next.js error boundaries\nexport function getErrorMessage(error: unknown): string {\n if (isConduitError(error)) {\n return error.message;\n }\n \n if (error instanceof Error) {\n return error.message;\n }\n \n return 'An unexpected error occurred';\n}\n\n// Helper for Next.js error pages\nexport function getErrorStatusCode(error: unknown): number {\n if (isConduitError(error)) {\n return error.statusCode;\n }\n \n return 500;\n}\n\n/**\n * Handle API errors and convert them to appropriate ConduitError types\n * This function is primarily used by the Admin SDK\n */\nexport function handleApiError(error: unknown, endpoint?: string, method?: string): never {\n const context: Record = {\n endpoint,\n method,\n };\n\n if (isHttpError(error)) {\n const { status, data } = error.response;\n const errorData = data as { error?: string; message?: string; details?: unknown } | null;\n const baseMessage = errorData?.error || errorData?.message || error.message;\n \n // Enhanced error messages with endpoint information\n const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : '';\n const enhancedMessage = `${baseMessage}${endpointInfo}`;\n \n // Add details to context\n context.details = errorData?.details || data;\n\n switch (status) {\n case 400:\n throw new ValidationError(enhancedMessage, context);\n case 401:\n throw new AuthError(enhancedMessage, context);\n case 402:\n throw new InsufficientBalanceError(enhancedMessage, context);\n case 403:\n throw new AuthorizationError(enhancedMessage, context);\n case 404:\n throw new NotFoundError(enhancedMessage, context);\n case 409:\n throw new ConflictError(enhancedMessage, context);\n case 429: {\n const retryAfterHeader = error.response.headers['retry-after'];\n const retryAfter = typeof retryAfterHeader === 'string' ? parseInt(retryAfterHeader, 10) : undefined;\n throw new RateLimitError(enhancedMessage, retryAfter, context);\n }\n case 500:\n case 502:\n case 503:\n case 504:\n throw new ServerError(enhancedMessage, context);\n default:\n throw new ConduitError(enhancedMessage, status, `HTTP_${status}`, context);\n }\n } else if (isHttpNetworkError(error)) {\n const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : '';\n context.code = error.code;\n \n if (error.code === 'ECONNABORTED') {\n throw new TimeoutError(`Request timeout${endpointInfo}`, context);\n }\n throw new NetworkError(`Network error: No response received${endpointInfo}`, context);\n } else if (isErrorLike(error)) {\n context.originalError = error;\n throw new ConduitError(error.message, 500, 'UNKNOWN_ERROR', context);\n } else {\n context.originalError = error;\n throw new ConduitError('Unknown error', 500, 'UNKNOWN_ERROR', context);\n }\n}\n\n/**\n * Create an error from an ErrorResponse format\n * This function is primarily used by the Core SDK for legacy compatibility\n */\nexport interface ErrorResponseFormat {\n error: {\n message: string;\n type?: string;\n code?: string;\n param?: string;\n };\n}\n\nexport function createErrorFromResponse(response: ErrorResponseFormat, statusCode?: number): ConduitError {\n const context: Record = {\n type: response.error.type,\n param: response.error.param,\n };\n \n return new ConduitError(\n response.error.message,\n statusCode || 500,\n response.error.code || 'API_ERROR',\n context\n );\n}","/**\n * HTTP methods enum for type-safe API requests\n */\nexport enum HttpMethod {\n GET = 'GET',\n POST = 'POST',\n PUT = 'PUT',\n DELETE = 'DELETE',\n PATCH = 'PATCH',\n HEAD = 'HEAD',\n OPTIONS = 'OPTIONS'\n}\n\n/**\n * Type guard to check if a string is a valid HTTP method\n */\nexport function isHttpMethod(method: string): method is HttpMethod {\n return Object.values(HttpMethod).includes(method as HttpMethod);\n}\n\n/**\n * Request options with proper typing\n */\nexport interface RequestOptions {\n headers?: Record;\n signal?: AbortSignal;\n timeout?: number;\n body?: TRequest;\n params?: Record;\n responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';\n}\n\n/**\n * Type-safe response interface\n */\nexport interface ApiResponse {\n data: T;\n status: number;\n statusText: string;\n headers: Record;\n}\n\n/**\n * Extended fetch options that include response type hints\n * This provides a cleaner way to handle different response types\n */\nexport interface ExtendedRequestInit extends RequestInit {\n /**\n * Hint for how to parse the response body\n * This is not a standard fetch option but helps our client handle responses correctly\n */\n responseType?: 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream';\n \n /**\n * Custom timeout in milliseconds\n */\n timeout?: number;\n \n /**\n * Request metadata for logging/debugging\n */\n metadata?: {\n /** Operation name for debugging */\n operation?: string;\n /** Start time for performance tracking */\n startTime?: number;\n /** Request ID for tracing */\n requestId?: string;\n };\n}","import { ExtendedRequestInit } from './types';\n\n/**\n * Response parser that handles different response types based on content-type and hints\n */\nexport class ResponseParser {\n /**\n * Parses a fetch Response based on content type and response type hint\n */\n static async parse(\n response: Response,\n responseType?: ExtendedRequestInit['responseType']\n ): Promise {\n // Handle empty responses\n const contentLength = response.headers.get('content-length');\n if (contentLength === '0' || response.status === 204) {\n return undefined as T;\n }\n \n // Use explicit responseType if provided\n if (responseType) {\n switch (responseType) {\n case 'json':\n return await response.json() as T;\n case 'text':\n return await response.text() as T;\n case 'blob':\n return await response.blob() as T;\n case 'arraybuffer':\n return await response.arrayBuffer() as T;\n case 'stream':\n if (!response.body) {\n throw new Error('Response body is not a stream');\n }\n return response.body as T;\n default: {\n // TypeScript exhaustiveness check\n const _exhaustive: never = responseType;\n throw new Error(`Unknown response type: ${String(_exhaustive)}`);\n }\n }\n }\n \n // Auto-detect based on content-type\n const contentType = response.headers.get('content-type') || '';\n \n if (contentType.includes('application/json')) {\n return await response.json() as T;\n }\n \n if (contentType.includes('text/') || contentType.includes('application/xml')) {\n return await response.text() as T;\n }\n \n if (contentType.includes('application/octet-stream') || \n contentType.includes('image/') ||\n contentType.includes('audio/') ||\n contentType.includes('video/')) {\n return await response.blob() as T;\n }\n \n // Default to text for unknown content types\n return await response.text() as T;\n }\n \n /**\n * Creates a clean RequestInit object without custom properties\n */\n static cleanRequestInit(init: ExtendedRequestInit): RequestInit {\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const { responseType, timeout, metadata, ...standardInit } = init;\n return standardInit;\n }\n}","/**\n * Common HTTP constants shared across all SDKs\n */\n\n/**\n * HTTP headers used across SDKs\n */\nexport const HTTP_HEADERS = {\n CONTENT_TYPE: 'Content-Type',\n AUTHORIZATION: 'Authorization',\n X_API_KEY: 'X-API-Key',\n USER_AGENT: 'User-Agent',\n X_CORRELATION_ID: 'X-Correlation-Id',\n RETRY_AFTER: 'Retry-After',\n ACCEPT: 'Accept',\n CACHE_CONTROL: 'Cache-Control'\n} as const;\n\nexport type HttpHeader = typeof HTTP_HEADERS[keyof typeof HTTP_HEADERS];\n\n/**\n * Content types\n */\nexport const CONTENT_TYPES = {\n JSON: 'application/json',\n FORM_DATA: 'multipart/form-data',\n FORM_URLENCODED: 'application/x-www-form-urlencoded',\n TEXT_PLAIN: 'text/plain',\n TEXT_STREAM: 'text/event-stream'\n} as const;\n\nexport type ContentType = typeof CONTENT_TYPES[keyof typeof CONTENT_TYPES];\n\n/**\n * HTTP status codes\n */\nexport const HTTP_STATUS = {\n // 2xx Success\n OK: 200,\n CREATED: 201,\n NO_CONTENT: 204,\n \n // 4xx Client Errors\n BAD_REQUEST: 400,\n UNAUTHORIZED: 401,\n FORBIDDEN: 403,\n NOT_FOUND: 404,\n CONFLICT: 409,\n TOO_MANY_REQUESTS: 429,\n RATE_LIMITED: 429, // Alias for Core SDK compatibility\n \n // 5xx Server Errors\n INTERNAL_SERVER_ERROR: 500,\n INTERNAL_ERROR: 500, // Alias for Admin SDK compatibility\n BAD_GATEWAY: 502,\n SERVICE_UNAVAILABLE: 503,\n GATEWAY_TIMEOUT: 504\n} as const;\n\nexport type HttpStatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];\n\n/**\n * Error codes for network errors\n */\nexport const ERROR_CODES = {\n CONNECTION_ABORTED: 'ECONNABORTED',\n TIMEOUT: 'ETIMEDOUT',\n CONNECTION_RESET: 'ECONNRESET',\n NETWORK_UNREACHABLE: 'ENETUNREACH',\n CONNECTION_REFUSED: 'ECONNREFUSED',\n HOST_NOT_FOUND: 'ENOTFOUND'\n} as const;\n\nexport type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES];\n\n/**\n * Default timeout values in milliseconds\n */\nexport const TIMEOUTS = {\n DEFAULT_REQUEST: 60000, // 60 seconds\n SHORT_REQUEST: 10000, // 10 seconds\n LONG_REQUEST: 300000, // 5 minutes\n STREAMING: 0 // No timeout for streaming\n} as const;\n\nexport type TimeoutValue = typeof TIMEOUTS[keyof typeof TIMEOUTS];\n\n/**\n * Retry configuration defaults\n */\nexport const RETRY_CONFIG = {\n DEFAULT_MAX_RETRIES: 3,\n INITIAL_DELAY: 1000, // 1 second\n MAX_DELAY: 30000, // 30 seconds\n BACKOFF_FACTOR: 2\n} as const;\n\nexport type RetryConfigValue = typeof RETRY_CONFIG[keyof typeof RETRY_CONFIG];","/**\n * SignalR hub connection states\n */\nexport enum HubConnectionState {\n Disconnected = 'Disconnected',\n Connecting = 'Connecting',\n Connected = 'Connected',\n Disconnecting = 'Disconnecting',\n Reconnecting = 'Reconnecting',\n}\n\n/**\n * SignalR logging levels\n */\nexport enum SignalRLogLevel {\n Trace = 0,\n Debug = 1,\n Information = 2,\n Warning = 3,\n Error = 4,\n Critical = 5,\n None = 6,\n}\n\n/**\n * HTTP transport types for SignalR\n */\nexport enum HttpTransportType {\n None = 0,\n WebSockets = 1,\n ServerSentEvents = 2,\n LongPolling = 4,\n}\n\n/**\n * Default transport configuration\n */\nexport const DefaultTransports =\n HttpTransportType.WebSockets |\n HttpTransportType.ServerSentEvents |\n HttpTransportType.LongPolling;\n\n/**\n * SignalR protocol types\n */\nexport enum SignalRProtocolType {\n /**\n * JSON protocol (default)\n */\n Json = 'json',\n /**\n * MessagePack binary protocol with compression\n */\n MessagePack = 'messagepack',\n}\n\n/**\n * Base SignalR connection options\n */\nexport interface SignalRConnectionOptions {\n /**\n * Logging level\n */\n logLevel?: SignalRLogLevel;\n \n /**\n * Transport types to use\n */\n transport?: HttpTransportType;\n \n /**\n * Headers to include with requests\n */\n headers?: Record;\n \n /**\n * Access token factory for authentication\n */\n accessTokenFactory?: () => string | Promise;\n \n /**\n * Close timeout in milliseconds\n */\n closeTimeout?: number;\n \n /**\n * Reconnection delay intervals in milliseconds\n */\n reconnectionDelay?: number[];\n \n /**\n * Server timeout in milliseconds\n */\n serverTimeout?: number;\n \n /**\n * Keep-alive interval in milliseconds\n */\n keepAliveInterval?: number;\n\n /**\n * Protocol to use for SignalR communication\n * @default SignalRProtocolType.Json\n */\n protocol?: SignalRProtocolType;\n}\n\n/**\n * Authentication configuration for SignalR connections\n */\nexport interface SignalRAuthConfig {\n /**\n * Authentication token or key\n */\n authToken: string;\n \n /**\n * Authentication type (e.g., 'master', 'virtual')\n */\n authType: 'master' | 'virtual';\n \n /**\n * Additional headers for authentication\n */\n additionalHeaders?: Record;\n}\n\n/**\n * SignalR hub method argument types for type safety\n */\nexport type SignalRPrimitive = string | number | boolean | null | undefined;\nexport type SignalRValue = SignalRPrimitive | SignalRArgs | SignalRPrimitive[];\nexport interface SignalRArgs {\n [key: string]: SignalRValue;\n}","import * as signalR from '@microsoft/signalr';\nimport {\n HubConnectionState,\n HttpTransportType,\n DefaultTransports,\n SignalRAuthConfig,\n SignalRConnectionOptions,\n SignalRLogLevel,\n SignalRProtocolType\n} from './types';\n\n// Lazy import for MessagePack protocol\nlet MessagePackHubProtocol: any;\n\n/**\n * Lazy loads the MessagePack protocol module\n */\nasync function loadMessagePackProtocol(): Promise {\n if (!MessagePackHubProtocol) {\n try {\n const msgpack = await import('@microsoft/signalr-protocol-msgpack');\n MessagePackHubProtocol = msgpack.MessagePackHubProtocol;\n return msgpack.MessagePackHubProtocol;\n } catch (error) {\n console.warn('MessagePack protocol not available, using JSON:', error);\n return null;\n }\n }\n return MessagePackHubProtocol;\n}\n\n/**\n * Base configuration for SignalR connections\n */\nexport interface BaseSignalRConfig {\n /**\n * Base URL for the SignalR hub\n */\n baseUrl: string;\n \n /**\n * Authentication configuration\n */\n auth: SignalRAuthConfig;\n \n /**\n * Connection options\n */\n options?: SignalRConnectionOptions;\n \n /**\n * User agent string\n */\n userAgent?: string;\n}\n\n/**\n * Base class for SignalR hub connections with automatic reconnection and error handling.\n * This abstract class provides common functionality for both Admin and Core SDKs.\n */\nexport abstract class BaseSignalRConnection {\n protected connection?: signalR.HubConnection;\n protected readonly config: BaseSignalRConfig;\n protected connectionReadyPromise: Promise;\n private connectionReadyResolve?: () => void;\n private connectionReadyReject?: (error: Error) => void;\n private disposed = false;\n\n /**\n * Gets the hub path for this connection type.\n */\n protected abstract get hubPath(): string;\n\n constructor(config: BaseSignalRConfig) {\n this.config = {\n ...config,\n baseUrl: config.baseUrl.replace(/\\/$/, '')\n };\n \n // Initialize the connection ready promise\n this.connectionReadyPromise = new Promise((resolve, reject) => {\n this.connectionReadyResolve = resolve;\n this.connectionReadyReject = reject;\n });\n }\n\n /**\n * Gets whether the connection is established and ready for use.\n */\n get isConnected(): boolean {\n return this.connection?.state === signalR.HubConnectionState.Connected;\n }\n\n /**\n * Gets the current connection state.\n */\n get state(): HubConnectionState {\n if (!this.connection) {\n return HubConnectionState.Disconnected;\n }\n\n switch (this.connection.state) {\n case signalR.HubConnectionState.Connected:\n return HubConnectionState.Connected;\n case signalR.HubConnectionState.Connecting:\n return HubConnectionState.Connecting;\n case signalR.HubConnectionState.Disconnected:\n return HubConnectionState.Disconnected;\n case signalR.HubConnectionState.Disconnecting:\n return HubConnectionState.Disconnecting;\n case signalR.HubConnectionState.Reconnecting:\n return HubConnectionState.Reconnecting;\n default:\n return HubConnectionState.Disconnected;\n }\n }\n\n /**\n * Event handlers\n */\n onConnected?: () => Promise;\n onDisconnected?: (error?: Error) => Promise;\n onReconnecting?: (error?: Error) => Promise;\n onReconnected?: (connectionId?: string) => Promise;\n\n /**\n * Establishes the SignalR connection.\n */\n protected async getConnection(): Promise {\n if (this.connection) {\n return this.connection;\n }\n\n const hubUrl = `${this.config.baseUrl}${this.hubPath}`;\n \n // Build connection options\n const connectionOptions: signalR.IHttpConnectionOptions = {\n accessTokenFactory: this.config.options?.accessTokenFactory || (() => this.config.auth.authToken),\n transport: this.mapTransportType(this.config.options?.transport || DefaultTransports),\n headers: this.buildHeaders(),\n withCredentials: false\n };\n \n // Build the connection\n const builder = new signalR.HubConnectionBuilder()\n .withUrl(hubUrl, connectionOptions)\n .withAutomaticReconnect(this.config.options?.reconnectionDelay || [0, 2000, 10000, 30000]);\n\n // Configure server timeout and keep-alive if specified\n if (this.config.options?.serverTimeout) {\n builder.withServerTimeout(this.config.options.serverTimeout);\n }\n \n if (this.config.options?.keepAliveInterval) {\n builder.withKeepAliveInterval(this.config.options.keepAliveInterval);\n }\n\n // Configure logging\n const logLevel = this.mapLogLevel(this.config.options?.logLevel || SignalRLogLevel.Information);\n builder.configureLogging(logLevel);\n\n // Configure protocol (JSON by default, MessagePack if specified)\n const protocolType = this.config.options?.protocol || SignalRProtocolType.Json;\n if (protocolType === SignalRProtocolType.MessagePack) {\n try {\n const MessagePackProtocol = await loadMessagePackProtocol();\n if (MessagePackProtocol) {\n builder.withHubProtocol(new MessagePackProtocol());\n console.warn('Using MessagePack protocol for SignalR connection');\n }\n } catch (error) {\n console.error('Failed to load MessagePack protocol, falling back to JSON:', error);\n // Continue with JSON (default) - graceful degradation\n }\n }\n\n this.connection = builder.build();\n\n // Set up event handlers\n this.connection.onclose(async (error) => {\n if (this.onDisconnected) {\n await this.onDisconnected(error);\n }\n });\n\n this.connection.onreconnecting(async (error) => {\n if (this.onReconnecting) {\n await this.onReconnecting(error);\n }\n });\n\n this.connection.onreconnected(async (connectionId) => {\n if (this.onReconnected) {\n await this.onReconnected(connectionId);\n }\n });\n\n // Configure hub-specific handlers\n this.configureHubHandlers(this.connection);\n\n try {\n await this.connection.start();\n \n if (this.connectionReadyResolve) {\n this.connectionReadyResolve();\n }\n \n if (this.onConnected) {\n await this.onConnected();\n }\n } catch (error) {\n if (this.connectionReadyReject) {\n this.connectionReadyReject(error as Error);\n }\n throw error;\n }\n\n return this.connection;\n }\n\n /**\n * Configures hub-specific event handlers. Override in derived classes.\n */\n protected abstract configureHubHandlers(connection: signalR.HubConnection): void;\n\n /**\n * Maps transport type enum to SignalR transport.\n */\n protected mapTransportType(transport: HttpTransportType): signalR.HttpTransportType {\n let result = signalR.HttpTransportType.None;\n \n if (transport & HttpTransportType.WebSockets) {\n result |= signalR.HttpTransportType.WebSockets;\n }\n if (transport & HttpTransportType.ServerSentEvents) {\n result |= signalR.HttpTransportType.ServerSentEvents;\n }\n if (transport & HttpTransportType.LongPolling) {\n result |= signalR.HttpTransportType.LongPolling;\n }\n \n return result;\n }\n\n /**\n * Maps log level enum to SignalR log level.\n */\n protected mapLogLevel(level: SignalRLogLevel): signalR.LogLevel {\n switch (level) {\n case SignalRLogLevel.Trace:\n return signalR.LogLevel.Trace;\n case SignalRLogLevel.Debug:\n return signalR.LogLevel.Debug;\n case SignalRLogLevel.Information:\n return signalR.LogLevel.Information;\n case SignalRLogLevel.Warning:\n return signalR.LogLevel.Warning;\n case SignalRLogLevel.Error:\n return signalR.LogLevel.Error;\n case SignalRLogLevel.Critical:\n return signalR.LogLevel.Critical;\n case SignalRLogLevel.None:\n return signalR.LogLevel.None;\n default:\n return signalR.LogLevel.Information;\n }\n }\n\n /**\n * Builds headers for the connection based on configuration.\n */\n private buildHeaders(): Record {\n const headers: Record = {\n 'User-Agent': this.config.userAgent || 'Conduit-Node-Client/1.0.0',\n ...this.config.options?.headers\n };\n\n // Add authentication-specific headers\n if (this.config.auth.authType === 'master' && this.config.auth.additionalHeaders) {\n Object.assign(headers, this.config.auth.additionalHeaders);\n }\n\n return headers;\n }\n\n /**\n * Waits for the connection to be ready.\n */\n public async waitForReady(): Promise {\n return this.connectionReadyPromise;\n }\n\n /**\n * Invokes a method on the hub with proper error handling.\n */\n protected async invoke(methodName: string, ...args: unknown[]): Promise {\n if (this.disposed) {\n throw new Error('Connection has been disposed');\n }\n\n const connection = await this.getConnection();\n \n try {\n return await connection.invoke(methodName, ...args);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n throw new Error(`SignalR invoke error for ${methodName}: ${errorMessage}`);\n }\n }\n\n /**\n * Sends a message to the hub without expecting a response.\n */\n protected async send(methodName: string, ...args: unknown[]): Promise {\n if (this.disposed) {\n throw new Error('Connection has been disposed');\n }\n\n const connection = await this.getConnection();\n \n try {\n await connection.send(methodName, ...args);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n throw new Error(`SignalR send error for ${methodName}: ${errorMessage}`);\n }\n }\n\n /**\n * Disconnects the SignalR connection.\n */\n public async disconnect(): Promise {\n if (this.connection && this.connection.state !== signalR.HubConnectionState.Disconnected) {\n await this.connection.stop();\n this.connection = undefined;\n \n // Reset the connection ready promise\n this.connectionReadyPromise = new Promise((resolve, reject) => {\n this.connectionReadyResolve = resolve;\n this.connectionReadyReject = reject;\n });\n }\n }\n\n /**\n * Disposes of the connection and cleans up resources.\n */\n public async dispose(): Promise {\n this.disposed = true;\n await this.disconnect();\n this.connectionReadyResolve = undefined;\n this.connectionReadyReject = undefined;\n }\n}","/**\n * Logger interface for client logging\n */\nexport interface Logger {\n debug(message: string, ...args: unknown[]): void;\n info(message: string, ...args: unknown[]): void;\n warn(message: string, ...args: unknown[]): void;\n error(message: string, ...args: unknown[]): void;\n}\n\n/**\n * Cache provider interface for client-side caching\n */\nexport interface CacheProvider {\n get(key: string): Promise;\n set(key: string, value: T, ttl?: number): Promise;\n delete(key: string): Promise;\n clear(): Promise;\n}\n\n/**\n * Base retry configuration interface\n * \n * Note: The Admin and Core SDKs have different retry strategies:\n * - Admin SDK uses simple fixed delay retry\n * - Core SDK uses exponential backoff\n * \n * This base interface supports both patterns.\n */\nexport interface RetryConfig {\n /**\n * Maximum number of retry attempts\n */\n maxRetries: number;\n \n /**\n * For Admin SDK: Fixed delay between retries in milliseconds\n * For Core SDK: Initial delay for exponential backoff\n */\n retryDelay?: number;\n \n /**\n * For Core SDK: Initial delay for exponential backoff\n */\n initialDelay?: number;\n \n /**\n * For Core SDK: Maximum delay between retries\n */\n maxDelay?: number;\n \n /**\n * For Core SDK: Backoff multiplication factor\n */\n factor?: number;\n \n /**\n * Custom retry condition function\n */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * HTTP error class\n */\nexport class HttpError extends Error {\n public code?: string;\n public response?: {\n status: number;\n data: unknown;\n headers: Record;\n };\n public request?: unknown;\n public config?: {\n url?: string;\n method?: string;\n _retry?: number;\n };\n\n constructor(message: string, code?: string) {\n super(message);\n this.name = 'HttpError';\n this.code = code;\n }\n}\n\n/**\n * Request configuration information\n */\nexport interface RequestConfigInfo {\n method: string;\n url: string;\n headers: Record;\n data?: unknown;\n params?: Record;\n}\n\n/**\n * Response information\n */\nexport interface ResponseInfo {\n status: number;\n statusText: string;\n headers: Record;\n data: unknown;\n config: RequestConfigInfo;\n}\n\n/**\n * Base client lifecycle callbacks\n */\nexport interface ClientLifecycleCallbacks {\n /**\n * Callback invoked on any error\n */\n onError?: (error: Error) => void;\n \n /**\n * Callback invoked before each request\n */\n onRequest?: (config: RequestConfigInfo) => void | Promise;\n \n /**\n * Callback invoked after each response\n */\n onResponse?: (response: ResponseInfo) => void | Promise;\n}\n\n/**\n * Base client configuration options\n */\nexport interface BaseClientOptions extends ClientLifecycleCallbacks {\n /**\n * Request timeout in milliseconds\n */\n timeout?: number;\n \n /**\n * Retry configuration\n */\n retries?: number | RetryConfig;\n \n /**\n * Logger instance for client logging\n */\n logger?: Logger;\n \n /**\n * Cache provider for response caching\n */\n cache?: CacheProvider;\n \n /**\n * Custom headers to include with all requests\n */\n headers?: Record;\n \n /**\n * Custom retry delays in milliseconds (overrides retry config)\n * @default [1000, 2000, 4000, 8000, 16000]\n */\n retryDelay?: number[];\n \n /**\n * Custom function to validate response status\n */\n validateStatus?: (status: number) => boolean;\n \n /**\n * Enable debug mode\n */\n debug?: boolean;\n}","/**\n * Retry strategy types and utilities for SDK HTTP clients\n * Supports both fixed delay (Admin SDK) and exponential backoff (Gateway SDK) patterns\n */\n\n/**\n * Type of retry strategy to use\n */\nexport enum RetryStrategyType {\n /** Fixed delay between retries (Admin SDK pattern) */\n FIXED_DELAY = 'fixed_delay',\n /** Exponential backoff with optional jitter (Gateway SDK pattern) */\n EXPONENTIAL_BACKOFF = 'exponential_backoff',\n /** Custom array of delays */\n CUSTOM_DELAYS = 'custom_delays'\n}\n\n/**\n * Fixed delay retry configuration\n * Used by Admin SDK for simple retry patterns\n */\nexport interface FixedDelayConfig {\n type: RetryStrategyType.FIXED_DELAY;\n /** Maximum number of retry attempts */\n maxRetries: number;\n /** Delay between retries in milliseconds */\n delayMs: number;\n /** Optional custom condition to determine if error is retryable */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * Exponential backoff retry configuration\n * Used by Gateway SDK for sophisticated retry patterns\n */\nexport interface ExponentialBackoffConfig {\n type: RetryStrategyType.EXPONENTIAL_BACKOFF;\n /** Maximum number of retry attempts */\n maxRetries: number;\n /** Initial delay in milliseconds */\n initialDelayMs: number;\n /** Maximum delay cap in milliseconds */\n maxDelayMs: number;\n /** Multiplication factor for each retry */\n factor: number;\n /** Whether to add random jitter to prevent thundering herd */\n jitter?: boolean;\n /** Optional custom condition to determine if error is retryable */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * Custom delays retry configuration\n * Allows specifying exact delay for each retry attempt\n */\nexport interface CustomDelaysConfig {\n type: RetryStrategyType.CUSTOM_DELAYS;\n /** Array of delays in milliseconds for each retry attempt */\n delays: number[];\n /** Optional custom condition to determine if error is retryable */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * Union type for all retry strategy configurations\n */\nexport type RetryStrategy = FixedDelayConfig | ExponentialBackoffConfig | CustomDelaysConfig;\n\n/**\n * Calculate the delay for a retry attempt based on the strategy\n * @param strategy - The retry strategy configuration\n * @param attempt - The current attempt number (1-based)\n * @returns Delay in milliseconds before the next retry\n */\nexport function calculateRetryDelay(\n strategy: RetryStrategy,\n attempt: number\n): number {\n switch (strategy.type) {\n case RetryStrategyType.FIXED_DELAY:\n return strategy.delayMs;\n\n case RetryStrategyType.EXPONENTIAL_BACKOFF: {\n const delay = Math.min(\n strategy.initialDelayMs * Math.pow(strategy.factor, attempt - 1),\n strategy.maxDelayMs\n );\n if (strategy.jitter) {\n // Add up to 1 second of random jitter\n return delay + Math.random() * 1000;\n }\n return delay;\n }\n\n case RetryStrategyType.CUSTOM_DELAYS: {\n // Use the last delay if attempt exceeds array length\n const index = Math.min(attempt - 1, strategy.delays.length - 1);\n return strategy.delays[index];\n }\n }\n}\n\n/**\n * Get the maximum number of retries for a strategy\n * @param strategy - The retry strategy configuration\n * @returns Maximum number of retry attempts\n */\nexport function getMaxRetries(strategy: RetryStrategy): number {\n switch (strategy.type) {\n case RetryStrategyType.FIXED_DELAY:\n case RetryStrategyType.EXPONENTIAL_BACKOFF:\n return strategy.maxRetries;\n case RetryStrategyType.CUSTOM_DELAYS:\n return strategy.delays.length;\n }\n}\n\n/**\n * Check if an error should be retried based on the strategy's condition\n * @param strategy - The retry strategy configuration\n * @param error - The error to check\n * @returns Whether the error should trigger a retry\n */\nexport function shouldRetryWithStrategy(\n strategy: RetryStrategy,\n error: unknown\n): boolean {\n if (strategy.retryCondition) {\n return strategy.retryCondition(error);\n }\n // Default: don't retry if no condition specified\n return false;\n}\n\n/**\n * Default retry strategies for each SDK type\n */\nexport const DEFAULT_RETRY_STRATEGIES = {\n /** Gateway SDK default: exponential backoff with jitter */\n gateway: {\n type: RetryStrategyType.EXPONENTIAL_BACKOFF,\n maxRetries: 3,\n initialDelayMs: 1000,\n maxDelayMs: 30000,\n factor: 2,\n jitter: true,\n } as ExponentialBackoffConfig,\n\n /** Admin SDK default: fixed delay */\n admin: {\n type: RetryStrategyType.FIXED_DELAY,\n maxRetries: 3,\n delayMs: 1000,\n } as FixedDelayConfig,\n};\n","/**\n * Abstract base API client providing common HTTP functionality\n *\n * SDK-specific clients extend this class and implement:\n * - getAuthHeaders(): Returns authentication headers\n * - getDefaultRetryStrategy(): Returns default retry strategy\n *\n * Template methods that can be overridden:\n * - handleErrorResponse(): SDK-specific error parsing\n * - shouldRetry(): SDK-specific retry logic\n * - getRetryDelay(): SDK-specific delay calculation\n */\n\nimport type { BaseApiClientConfig } from './base-client-config';\nimport type { Logger, CacheProvider, RequestConfigInfo, ResponseInfo } from './types';\nimport type { RetryStrategy } from './retry-strategy';\nimport { calculateRetryDelay, getMaxRetries } from './retry-strategy';\nimport { ResponseParser } from '../http/parser';\nimport { HttpMethod, type ExtendedRequestInit } from '../http/types';\nimport { HTTP_HEADERS, CONTENT_TYPES } from '../http/constants';\nimport { ConduitError } from '../errors';\n\n/**\n * Request options for individual requests\n */\nexport interface BaseRequestOptions {\n /** Additional headers for this request */\n headers?: Record;\n /** AbortSignal for request cancellation */\n signal?: AbortSignal;\n /** Request timeout in milliseconds (overrides client default) */\n timeout?: number;\n /** Expected response type */\n responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';\n}\n\n/**\n * Abstract base API client providing common HTTP functionality\n *\n * Both Gateway SDK and Admin SDK extend this class.\n */\nexport abstract class BaseApiClient {\n /** Base URL for all requests (without trailing slash) */\n protected readonly baseUrl: string;\n /** Default timeout in milliseconds */\n protected readonly timeout: number;\n /** Default headers included with all requests */\n protected readonly defaultHeaders: Record;\n /** Retry strategy configuration */\n protected readonly retryStrategy: RetryStrategy;\n /** Enable debug logging */\n protected readonly debug: boolean;\n\n // Lifecycle callbacks\n protected readonly onError?: (error: Error) => void;\n protected readonly onRequest?: (config: RequestConfigInfo) => void | Promise;\n protected readonly onResponse?: (response: ResponseInfo) => void | Promise;\n\n // Optional providers (Admin SDK uses these, Gateway SDK may not)\n protected readonly logger?: Logger;\n protected readonly cache?: CacheProvider;\n\n constructor(config: BaseApiClientConfig) {\n this.baseUrl = config.baseUrl.replace(/\\/$/, '');\n this.timeout = config.timeout ?? 60000;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.retryStrategy = config.retryStrategy ?? this.getDefaultRetryStrategy();\n this.debug = config.debug ?? false;\n\n this.onError = config.onError;\n this.onRequest = config.onRequest;\n this.onResponse = config.onResponse;\n this.logger = config.logger;\n this.cache = config.cache;\n }\n\n // ============================================================================\n // Abstract Methods - Must be implemented by SDK-specific clients\n // ============================================================================\n\n /**\n * Returns authentication headers for this SDK\n *\n * Gateway SDK returns: { Authorization: 'Bearer ...' }\n * Admin SDK returns: { 'X-Master-Key': '...' }\n */\n protected abstract getAuthHeaders(): Record;\n\n /**\n * Returns default retry strategy for this SDK\n *\n * Gateway SDK uses exponential backoff with jitter\n * Admin SDK uses fixed delay\n */\n protected abstract getDefaultRetryStrategy(): RetryStrategy;\n\n // ============================================================================\n // Template Methods - Can be overridden by SDK-specific clients\n // ============================================================================\n\n /**\n * Transform error response into appropriate error type\n * Subclasses can override for SDK-specific error handling\n *\n * @param response - The failed Response object\n * @returns An Error to throw\n */\n protected async handleErrorResponse(response: Response): Promise {\n let errorData: unknown;\n try {\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n errorData = await response.json();\n }\n } catch {\n errorData = {};\n }\n\n // Default implementation - subclasses can override for richer error handling\n return new ConduitError(\n `HTTP ${response.status}: ${response.statusText}`,\n response.status,\n `HTTP_${response.status}`,\n { data: errorData }\n );\n }\n\n /**\n * Determine if an error should be retried\n * Subclasses can override for SDK-specific retry logic\n *\n * @param error - The error that occurred\n * @param attempt - Current attempt number (1-based)\n * @returns Whether to retry the request\n */\n protected shouldRetry(error: unknown, attempt: number): boolean {\n const maxRetries = getMaxRetries(this.retryStrategy);\n if (attempt > maxRetries) return false;\n\n // Check custom retry condition if provided\n if (this.retryStrategy.retryCondition) {\n return this.retryStrategy.retryCondition(error);\n }\n\n // Default retry logic\n if (error instanceof ConduitError) {\n // Retry rate limits and server errors\n return error.statusCode === 429 || error.statusCode >= 500;\n }\n\n if (error instanceof Error) {\n // Network errors are retryable\n return (\n error.name === 'AbortError' ||\n error.message.includes('network') ||\n error.message.includes('fetch')\n );\n }\n\n return false;\n }\n\n /**\n * Calculate delay for a retry attempt\n * Subclasses can override for special cases (e.g., retry-after headers)\n *\n * @param error - The error that triggered the retry\n * @param attempt - Current attempt number (1-based)\n * @returns Delay in milliseconds before next retry\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n protected getRetryDelay(_error: unknown, attempt: number): number {\n return calculateRetryDelay(this.retryStrategy, attempt);\n }\n\n // ============================================================================\n // HTTP Methods\n // ============================================================================\n\n /**\n * Main request method with retry logic\n */\n protected async request(\n url: string,\n options: BaseRequestOptions & { method?: HttpMethod; body?: TRequest } = {}\n ): Promise {\n const fullUrl = this.buildUrl(url);\n const controller = new AbortController();\n\n const timeoutMs = options.timeout ?? this.timeout;\n const timeoutId = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const requestInfo: RequestConfigInfo = {\n method: options.method ?? HttpMethod.GET,\n url: fullUrl,\n headers: this.buildHeaders(options.headers),\n data: options.body,\n };\n\n // Call onRequest hook if provided\n if (this.onRequest) {\n await this.onRequest(requestInfo);\n }\n\n this.log('debug', `API Request: ${requestInfo.method} ${requestInfo.url}`);\n\n const response = await this.executeWithRetry(\n fullUrl,\n {\n method: requestInfo.method,\n headers: requestInfo.headers,\n body: options.body ? JSON.stringify(options.body) : undefined,\n signal: options.signal ?? controller.signal,\n responseType: options.responseType,\n timeout: timeoutMs,\n }\n );\n\n return response;\n } finally {\n clearTimeout(timeoutId);\n }\n }\n\n /**\n * Type-safe GET request\n */\n protected async get(\n url: string,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, { ...options, method: HttpMethod.GET });\n }\n\n /**\n * Type-safe POST request\n */\n protected async post(\n url: string,\n data?: TRequest,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, {\n ...options,\n method: HttpMethod.POST,\n body: data,\n });\n }\n\n /**\n * Type-safe PUT request\n */\n protected async put(\n url: string,\n data?: TRequest,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, {\n ...options,\n method: HttpMethod.PUT,\n body: data,\n });\n }\n\n /**\n * Type-safe PATCH request\n */\n protected async patch(\n url: string,\n data?: TRequest,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, {\n ...options,\n method: HttpMethod.PATCH,\n body: data,\n });\n }\n\n /**\n * Type-safe DELETE request\n */\n protected async delete(\n url: string,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, { ...options, method: HttpMethod.DELETE });\n }\n\n // ============================================================================\n // Internal Methods\n // ============================================================================\n\n /**\n * Execute request with retry logic\n */\n private async executeWithRetry(\n url: string,\n init: ExtendedRequestInit,\n attempt: number = 1\n ): Promise {\n try {\n const response = await fetch(url, ResponseParser.cleanRequestInit(init));\n\n this.log('debug', `API Response: ${response.status} ${response.statusText}`);\n\n // Build response info for callback\n const headers: Record = {};\n response.headers.forEach((value, key) => {\n headers[key] = value;\n });\n\n // Call onResponse hook if provided\n if (this.onResponse) {\n const responseInfo: ResponseInfo = {\n status: response.status,\n statusText: response.statusText,\n headers,\n data: undefined,\n config: {\n url,\n method: (init.method as string) ?? HttpMethod.GET,\n headers: (init.headers as Record) ?? {},\n },\n };\n await this.onResponse(responseInfo);\n }\n\n if (!response.ok) {\n const error = await this.handleErrorResponse(response);\n throw error;\n }\n\n // Handle empty responses\n const contentLength = response.headers.get('content-length');\n if (contentLength === '0' || response.status === 204) {\n return undefined as TResponse;\n }\n\n return await ResponseParser.parse(response, init.responseType);\n } catch (error) {\n if (this.shouldRetry(error, attempt)) {\n const delay = this.getRetryDelay(error, attempt);\n this.log('debug', `Retrying request (attempt ${attempt + 1}) after ${delay}ms`);\n\n await this.sleep(delay);\n return this.executeWithRetry(url, init, attempt + 1);\n }\n\n // Call error handler and rethrow\n if (this.onError && error instanceof Error) {\n this.onError(error);\n }\n throw error;\n }\n }\n\n /**\n * Build full URL from path\n */\n private buildUrl(path: string): string {\n // If path is already a full URL, return it\n if (path.startsWith('http://') || path.startsWith('https://')) {\n return path;\n }\n\n // Ensure path starts with /\n const cleanPath = path.startsWith('/') ? path : `/${path}`;\n return `${this.baseUrl}${cleanPath}`;\n }\n\n /**\n * Build headers including auth, defaults, and additional headers\n */\n private buildHeaders(additionalHeaders?: Record): Record {\n return {\n [HTTP_HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON,\n ...this.getAuthHeaders(),\n ...this.defaultHeaders,\n ...additionalHeaders,\n };\n }\n\n /**\n * Log a message using the configured logger or console in debug mode\n */\n protected log(\n level: 'debug' | 'info' | 'warn' | 'error',\n message: string,\n ...args: unknown[]\n ): void {\n if (this.logger?.[level]) {\n this.logger[level](message, ...args);\n } else if (this.debug && level === 'debug') {\n console.warn(`[SDK] ${message}`, ...args);\n }\n }\n\n /**\n * Sleep for a specified duration\n */\n private sleep(ms: number): Promise {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n\n // ============================================================================\n // Caching Utilities (Optional - only active if cache provider is configured)\n // ============================================================================\n\n /**\n * Get a value from cache\n * Returns null if cache is not configured or key is not found\n */\n protected async getFromCache(key: string): Promise {\n if (!this.cache) return null;\n\n try {\n const cached = await this.cache.get(key);\n if (cached) {\n this.log('debug', `Cache hit for key: ${key}`);\n return cached;\n }\n } catch (error) {\n this.log('error', 'Cache get error:', error);\n }\n\n return null;\n }\n\n /**\n * Set a value in cache\n * No-op if cache is not configured\n */\n protected async setCache(key: string, value: unknown, ttl?: number): Promise {\n if (!this.cache) return;\n\n try {\n await this.cache.set(key, value, ttl);\n this.log('debug', `Cache set for key: ${key}`);\n } catch (error) {\n this.log('error', 'Cache set error:', error);\n }\n }\n\n /**\n * Execute a function with caching\n * Returns cached value if available, otherwise executes function and caches result\n */\n protected async withCache(\n cacheKey: string,\n fn: () => Promise,\n ttl?: number\n ): Promise {\n const cached = await this.getFromCache(cacheKey);\n if (cached !== null) {\n return cached;\n }\n\n const result = await fn();\n await this.setCache(cacheKey, result, ttl);\n\n return result;\n }\n\n /**\n * Generate a cache key from resource and identifiers\n */\n protected getCacheKey(\n resource: string,\n ...identifiers: (string | number | Record | undefined)[]\n ): string {\n const parts = identifiers\n .filter((id) => id !== undefined)\n .map((id) => (typeof id === 'object' ? JSON.stringify(id) : String(id)));\n return `${resource}:${parts.join(':')}`;\n }\n}\n","/**\n * Circuit breaker types and interfaces\n *\n * Provides types for implementing the circuit breaker pattern to prevent\n * cascading failures and protect against sustained service degradation.\n */\n\n/**\n * Circuit breaker states following the standard pattern\n */\nexport enum CircuitState {\n /** Normal operation - requests pass through, failures tracked */\n CLOSED = 'closed',\n /** Circuit tripped - requests are blocked/rejected immediately */\n OPEN = 'open',\n /** Testing recovery - limited requests allowed to test if service recovered */\n HALF_OPEN = 'half_open'\n}\n\n/**\n * Configuration options for the circuit breaker\n */\nexport interface CircuitBreakerConfig {\n /** Number of consecutive failures to trip the circuit (default: 3) */\n failureThreshold?: number;\n\n /** Time window in milliseconds for counting failures (default: 60000) */\n failureWindowMs?: number;\n\n /** Time in milliseconds to wait before transitioning from OPEN to HALF_OPEN (default: 30000) */\n resetTimeoutMs?: number;\n\n /** Number of successful requests in HALF_OPEN to close circuit (default: 1) */\n successThreshold?: number;\n\n /** Enable debug logging (default: false) */\n enableLogging?: boolean;\n\n /** Custom function to determine if an error should count as a failure */\n shouldCountAsFailure?: (error: unknown) => boolean;\n}\n\n/**\n * Statistics about the circuit breaker state\n */\nexport interface CircuitBreakerStats {\n /** Current state of the circuit */\n state: CircuitState;\n\n /** Number of consecutive failures in current window */\n consecutiveFailures: number;\n\n /** Total failures since last reset */\n totalFailures: number;\n\n /** Total successes since last reset */\n totalSuccesses: number;\n\n /** Timestamp when circuit was opened (null if closed) */\n circuitOpenedAt: number | null;\n\n /** Time remaining until HALF_OPEN transition in ms (null if not OPEN) */\n timeUntilHalfOpen: number | null;\n\n /** Timestamp of last failure */\n lastFailureAt: number | null;\n\n /** Timestamp of last success */\n lastSuccessAt: number | null;\n\n /** Number of requests rejected while OPEN */\n rejectedRequests: number;\n}\n\n/**\n * Callbacks for circuit breaker state changes\n */\nexport interface CircuitBreakerCallbacks {\n /** Called when circuit transitions to OPEN state */\n onOpen?: (stats: CircuitBreakerStats, error: unknown) => void;\n\n /** Called when circuit transitions to HALF_OPEN state */\n onHalfOpen?: (stats: CircuitBreakerStats) => void;\n\n /** Called when circuit transitions to CLOSED state */\n onClose?: (stats: CircuitBreakerStats) => void;\n\n /** Called when a request is rejected due to OPEN circuit */\n onRejected?: (stats: CircuitBreakerStats) => void;\n\n /** Called on any state change */\n onStateChange?: (oldState: CircuitState, newState: CircuitState, stats: CircuitBreakerStats) => void;\n}\n","/**\n * Circuit breaker error types\n */\n\nimport { ConduitError } from '../errors';\nimport type { CircuitState, CircuitBreakerStats } from './types';\n\n/**\n * Error thrown when circuit breaker is open and request is rejected\n */\nexport class CircuitBreakerOpenError extends ConduitError {\n /** Current circuit breaker state */\n public readonly circuitState: CircuitState;\n\n /** Time until circuit transitions to HALF_OPEN (milliseconds) */\n public readonly timeUntilHalfOpen: number | null;\n\n /** Circuit breaker statistics at time of rejection */\n public readonly stats: CircuitBreakerStats;\n\n constructor(\n message: string,\n stats: CircuitBreakerStats,\n timeUntilHalfOpen: number | null\n ) {\n super(message, 503, 'CIRCUIT_BREAKER_OPEN', {\n circuitState: stats.state,\n timeUntilHalfOpen,\n consecutiveFailures: stats.consecutiveFailures,\n totalFailures: stats.totalFailures\n });\n\n this.circuitState = stats.state;\n this.timeUntilHalfOpen = timeUntilHalfOpen;\n this.stats = stats;\n }\n}\n\n/**\n * Type guard for CircuitBreakerOpenError\n */\nexport function isCircuitBreakerOpenError(error: unknown): error is CircuitBreakerOpenError {\n return error instanceof CircuitBreakerOpenError;\n}\n","/**\n * Circuit breaker implementation for preventing cascading failures\n *\n * Implements the circuit breaker pattern with three states:\n * - CLOSED: Normal operation, counting failures\n * - OPEN: Circuit tripped, rejecting requests\n * - HALF_OPEN: Testing recovery with limited requests\n */\n\nimport { CircuitState } from './types';\nimport type { CircuitBreakerConfig, CircuitBreakerStats, CircuitBreakerCallbacks } from './types';\nimport { CircuitBreakerOpenError } from './errors';\n\n/**\n * Default configuration values matching Issue #896 requirements\n */\nconst DEFAULT_CONFIG: Required> = {\n failureThreshold: 3,\n failureWindowMs: 60000, // 60 seconds\n resetTimeoutMs: 30000, // 30 seconds\n successThreshold: 1,\n enableLogging: false\n};\n\ninterface FailureRecord {\n timestamp: number;\n error: unknown;\n}\n\n/**\n * Circuit breaker implementation for preventing cascading failures\n *\n * State machine:\n * - CLOSED: Normal operation, counting failures\n * - OPEN: Circuit tripped, rejecting requests\n * - HALF_OPEN: Testing recovery with limited requests\n */\nexport class CircuitBreaker {\n private readonly config: Required> &\n Pick;\n private readonly callbacks: CircuitBreakerCallbacks;\n\n // State tracking\n private state: CircuitState = CircuitState.CLOSED;\n private failures: FailureRecord[] = [];\n private halfOpenSuccesses: number = 0;\n\n // Statistics\n private totalFailures: number = 0;\n private totalSuccesses: number = 0;\n private rejectedRequests: number = 0;\n private circuitOpenedAt: number | null = null;\n private lastFailureAt: number | null = null;\n private lastSuccessAt: number | null = null;\n\n constructor(\n config: CircuitBreakerConfig = {},\n callbacks: CircuitBreakerCallbacks = {}\n ) {\n this.config = {\n ...DEFAULT_CONFIG,\n ...config\n };\n this.callbacks = callbacks;\n }\n\n /**\n * Get current state of the circuit\n * Automatically transitions OPEN -> HALF_OPEN after timeout\n */\n getState(): CircuitState {\n // Check if OPEN circuit should transition to HALF_OPEN\n if (this.state === CircuitState.OPEN && this.circuitOpenedAt !== null) {\n const elapsed = Date.now() - this.circuitOpenedAt;\n if (elapsed >= this.config.resetTimeoutMs) {\n this.transitionTo(CircuitState.HALF_OPEN);\n }\n }\n return this.state;\n }\n\n /**\n * Get circuit breaker statistics\n */\n getStats(): CircuitBreakerStats {\n const currentState = this.getState();\n return {\n state: currentState,\n consecutiveFailures: this.getConsecutiveFailuresInWindow(),\n totalFailures: this.totalFailures,\n totalSuccesses: this.totalSuccesses,\n circuitOpenedAt: this.circuitOpenedAt,\n timeUntilHalfOpen: this.calculateTimeUntilHalfOpen(),\n lastFailureAt: this.lastFailureAt,\n lastSuccessAt: this.lastSuccessAt,\n rejectedRequests: this.rejectedRequests\n };\n }\n\n /**\n * Check if a request can proceed\n * Returns true if circuit is CLOSED or HALF_OPEN\n */\n canExecute(): boolean {\n const state = this.getState();\n return state !== CircuitState.OPEN;\n }\n\n /**\n * Check if request should proceed, throwing if circuit is open\n * @throws CircuitBreakerOpenError if circuit is OPEN\n */\n checkOpen(): void {\n const state = this.getState();\n if (state === CircuitState.OPEN) {\n this.rejectedRequests++;\n const stats = this.getStats();\n this.callbacks.onRejected?.(stats);\n\n throw new CircuitBreakerOpenError(\n `Circuit breaker is open. Try again in ${Math.ceil((stats.timeUntilHalfOpen ?? 0) / 1000)} seconds.`,\n stats,\n stats.timeUntilHalfOpen\n );\n }\n }\n\n /**\n * Record a successful request\n */\n recordSuccess(): void {\n this.totalSuccesses++;\n this.lastSuccessAt = Date.now();\n\n const currentState = this.getState();\n\n if (currentState === CircuitState.HALF_OPEN) {\n this.halfOpenSuccesses++;\n this.log('debug', `Half-open success ${this.halfOpenSuccesses}/${this.config.successThreshold}`);\n\n if (this.halfOpenSuccesses >= this.config.successThreshold) {\n this.transitionTo(CircuitState.CLOSED);\n }\n } else if (currentState === CircuitState.CLOSED) {\n // Clear failure history on success in CLOSED state\n this.failures = [];\n }\n }\n\n /**\n * Record a failed request\n */\n recordFailure(error: unknown): void {\n // Check if this error should count as a failure\n if (this.config.shouldCountAsFailure && !this.config.shouldCountAsFailure(error)) {\n this.log('debug', 'Error not counted as failure by custom filter');\n return;\n }\n\n const now = Date.now();\n this.totalFailures++;\n this.lastFailureAt = now;\n\n const currentState = this.getState();\n\n if (currentState === CircuitState.HALF_OPEN) {\n // Any failure in HALF_OPEN immediately reopens the circuit\n this.log('warn', 'Failure in half-open state, reopening circuit');\n this.transitionTo(CircuitState.OPEN, error);\n return;\n }\n\n if (currentState === CircuitState.CLOSED) {\n // Add to failure history\n this.failures.push({ timestamp: now, error });\n\n // Clean up old failures outside the window\n this.pruneOldFailures();\n\n // Check if we should trip the circuit\n const consecutiveFailures = this.getConsecutiveFailuresInWindow();\n this.log('debug', `Consecutive failures: ${consecutiveFailures}/${this.config.failureThreshold}`);\n\n if (consecutiveFailures >= this.config.failureThreshold) {\n this.transitionTo(CircuitState.OPEN, error);\n }\n }\n }\n\n /**\n * Manually reset the circuit to CLOSED state\n * Use with caution - typically for testing or admin override\n */\n reset(): void {\n this.log('info', 'Circuit manually reset');\n this.transitionTo(CircuitState.CLOSED);\n this.failures = [];\n this.totalFailures = 0;\n this.totalSuccesses = 0;\n this.rejectedRequests = 0;\n }\n\n // Private methods\n\n private transitionTo(newState: CircuitState, triggerError?: unknown): void {\n const oldState = this.state;\n if (oldState === newState) return;\n\n this.state = newState;\n const stats = this.getStats();\n\n this.log('info', `Circuit state change: ${oldState} -> ${newState}`);\n\n switch (newState) {\n case CircuitState.OPEN:\n this.circuitOpenedAt = Date.now();\n this.halfOpenSuccesses = 0;\n this.callbacks.onOpen?.(stats, triggerError);\n break;\n\n case CircuitState.HALF_OPEN:\n this.halfOpenSuccesses = 0;\n this.callbacks.onHalfOpen?.(stats);\n break;\n\n case CircuitState.CLOSED:\n this.circuitOpenedAt = null;\n this.failures = [];\n this.halfOpenSuccesses = 0;\n this.callbacks.onClose?.(stats);\n break;\n }\n\n this.callbacks.onStateChange?.(oldState, newState, stats);\n }\n\n private pruneOldFailures(): void {\n const cutoff = Date.now() - this.config.failureWindowMs;\n this.failures = this.failures.filter(f => f.timestamp >= cutoff);\n }\n\n private getConsecutiveFailuresInWindow(): number {\n this.pruneOldFailures();\n return this.failures.length;\n }\n\n private calculateTimeUntilHalfOpen(): number | null {\n if (this.state !== CircuitState.OPEN || this.circuitOpenedAt === null) {\n return null;\n }\n\n const elapsed = Date.now() - this.circuitOpenedAt;\n const remaining = this.config.resetTimeoutMs - elapsed;\n return remaining > 0 ? remaining : 0;\n }\n\n private log(_level: 'debug' | 'info' | 'warn' | 'error', message: string): void {\n if (this.config.enableLogging) {\n console.warn(`[CircuitBreaker] ${message}`);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACOO,IAAK,kBAAL,kBAAKA,qBAAL;AACL,EAAAA,iBAAA,UAAO;AACP,EAAAA,iBAAA,YAAS;AACT,EAAAA,iBAAA,sBAAmB;AACnB,EAAAA,iBAAA,gBAAa;AACb,EAAAA,iBAAA,qBAAkB;AAClB,EAAAA,iBAAA,yBAAsB;AACtB,EAAAA,iBAAA,oBAAiB;AACjB,EAAAA,iBAAA,oBAAiB;AACjB,EAAAA,iBAAA,gBAAa;AACb,EAAAA,iBAAA,sBAAmB;AAVT,SAAAA;AAAA,GAAA;AAkDL,SAAS,yBAAyB,YAAqC;AAC5E,UAAQ,YAAY;AAAA,IAClB,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAKO,SAAS,sBAAsB,YAAoE;AACxG,UAAQ,YAAY;AAAA,IAClB,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;;;ACnGO,IAAM,eAAN,MAAM,sBAAqB,MAAM;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EAEP,YACE,SACA,aAAqB,KACrB,OAAe,kBACf,SACA;AACA,UAAM,OAAO;AACb,SAAK,OAAO,KAAK,YAAY;AAC7B,SAAK,aAAa;AAClB,SAAK,OAAO;AACZ,SAAK,UAAU;AAGf,QAAI,SAAS;AAEX,WAAK,UAAU,QAAQ;AACvB,WAAK,WAAW,QAAQ;AACxB,WAAK,SAAS,QAAQ;AAGtB,WAAK,OAAO,QAAQ;AACpB,WAAK,QAAQ,QAAQ;AAAA,IACvB;AAGA,WAAO,eAAe,MAAM,WAAW,SAAS;AAGhD,QAAI,MAAM,mBAAmB;AAC3B,YAAM,kBAAkB,MAAM,KAAK,WAAW;AAAA,IAChD;AAAA,EACF;AAAA,EAEA,SAAS;AACP,WAAO;AAAA,MACL,MAAM,KAAK;AAAA,MACX,SAAS,KAAK;AAAA,MACd,YAAY,KAAK;AAAA,MACjB,MAAM,KAAK;AAAA,MACX,SAAS,KAAK;AAAA,MACd,SAAS,KAAK;AAAA,MACd,UAAU,KAAK;AAAA,MACf,QAAQ,KAAK;AAAA,MACb,MAAM,KAAK;AAAA,MACX,OAAO,KAAK;AAAA,MACZ,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IACpC;AAAA,EACF;AAAA;AAAA,EAGA,iBAAiB;AACf,WAAO;AAAA,MACL,gBAAgB;AAAA,MAChB,GAAG,KAAK,OAAO;AAAA,IACjB;AAAA,EACF;AAAA;AAAA,EAGA,OAAO,iBAAiB,MAA6B;AACnD,QAAI,CAAC,QAAQ,OAAO,SAAS,YAAY,EAAE,oBAAoB,SAAS,CAAE,KAAqC,gBAAgB;AAC7H,YAAM,IAAI,MAAM,iCAAiC;AAAA,IACnD;AAEA,UAAM,YAAY;AAYlB,UAAM,QAAQ,IAAI;AAAA,MAChB,UAAU;AAAA,MACV,UAAU;AAAA,MACV,UAAU;AAAA,MACV,UAAU;AAAA,IACZ;AAGA,QAAI,UAAU,YAAY,OAAW,OAAM,UAAU,UAAU;AAC/D,QAAI,UAAU,aAAa,OAAW,OAAM,WAAW,UAAU;AACjE,QAAI,UAAU,WAAW,OAAW,OAAM,SAAS,UAAU;AAC7D,QAAI,UAAU,SAAS,OAAW,OAAM,OAAO,UAAU;AACzD,QAAI,UAAU,UAAU,OAAW,OAAM,QAAQ,UAAU;AAE3D,WAAO;AAAA,EACT;AACF;AAEO,IAAM,YAAN,cAAwB,aAAa;AAAA,EAC1C,YAAY,UAAU,yBAAyB,SAAmC;AAChF,UAAM,SAAS,KAAK,cAAc,OAAO;AAAA,EAC3C;AACF;AAGO,IAAM,sBAAN,cAAkC,UAAU;AAAC;AAE7C,IAAM,qBAAN,cAAiC,aAAa;AAAA,EACnD,YAAY,UAAU,oBAAoB,SAAmC;AAC3E,UAAM,SAAS,KAAK,uBAAuB,OAAO;AAAA,EACpD;AACF;AAEO,IAAM,kBAAN,cAA8B,aAAa;AAAA,EACzC;AAAA,EAEP,YAAY,UAAU,qBAAqB,SAAmC;AAC5E,UAAM,SAAS,KAAK,oBAAoB,OAAO;AAC/C,SAAK,QAAQ,SAAS;AAAA,EACxB;AACF;AAEO,IAAM,gBAAN,cAA4B,aAAa;AAAA,EAC9C,YAAY,UAAU,sBAAsB,SAAmC;AAC7E,UAAM,SAAS,KAAK,aAAa,OAAO;AAAA,EAC1C;AACF;AAEO,IAAM,gBAAN,cAA4B,aAAa;AAAA,EAC9C,YAAY,UAAU,qBAAqB,SAAmC;AAC5E,UAAM,SAAS,KAAK,kBAAkB,OAAO;AAAA,EAC/C;AACF;AAEO,IAAM,2BAAN,cAAuC,aAAa;AAAA,EAClD;AAAA,EACA;AAAA,EAEP,YAAY,UAAU,4CAA4C,SAAmC;AACnG,UAAM,SAAS,KAAK,wBAAwB,OAAO;AACnD,SAAK,UAAU,SAAS;AACxB,SAAK,iBAAiB,SAAS;AAAA,EACjC;AACF;AAEO,IAAM,iBAAN,cAA6B,aAAa;AAAA,EACxC;AAAA,EAEP,YAAY,UAAU,uBAAuB,YAAqB,SAAmC;AACnG,UAAM,SAAS,KAAK,oBAAoB,EAAE,GAAG,SAAS,WAAW,CAAC;AAClE,SAAK,aAAa;AAAA,EACpB;AACF;AAEO,IAAM,cAAN,cAA0B,aAAa;AAAA,EAC5C,YAAY,UAAU,yBAAyB,SAAmC;AAChF,UAAM,SAAS,KAAK,gBAAgB,OAAO;AAAA,EAC7C;AACF;AAEO,IAAM,eAAN,cAA2B,aAAa;AAAA,EAC7C,YAAY,UAAU,iBAAiB,SAAmC;AACxE,UAAM,SAAS,GAAG,iBAAiB,OAAO;AAAA,EAC5C;AACF;AAEO,IAAM,eAAN,cAA2B,aAAa;AAAA,EAC7C,YAAY,UAAU,mBAAmB,SAAmC;AAC1E,UAAM,SAAS,KAAK,iBAAiB,OAAO;AAAA,EAC9C;AACF;AAEO,IAAM,sBAAN,cAAkC,aAAa;AAAA,EACpD,YAAY,SAAiB,SAAmC;AAC9D,UAAM,SAAS,KAAK,mBAAmB,OAAO;AAAA,EAChD;AACF;AAEO,IAAM,cAAN,cAA0B,aAAa;AAAA,EAC5C,YAAY,UAAU,4BAA4B,SAAmC;AACnF,UAAM,SAAS,KAAK,gBAAgB,OAAO;AAAA,EAC7C;AACF;AAGO,SAAS,eAAe,OAAuC;AACpE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,YAAY,OAAoC;AAC9D,SAAO,iBAAiB,aAAa,iBAAiB;AACxD;AAEO,SAAS,qBAAqB,OAA6C;AAChF,SAAO,iBAAiB;AAC1B;AAEO,SAAS,kBAAkB,OAA0C;AAC1E,SAAO,iBAAiB;AAC1B;AAEO,SAAS,gBAAgB,OAAwC;AACtE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,gBAAgB,OAAwC;AACtE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,2BAA2B,OAAmD;AAC5F,SAAO,iBAAiB;AAC1B;AAEO,SAAS,iBAAiB,OAAyC;AACxE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,eAAe,OAAuC;AACpE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,cAAc,OAAsC;AAClE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,eAAe,OAAuC;AACpE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,cAAc,OAAuC;AACnE,SAAO,eAAe,KAAK,KACpB,MAAM,eAAe,UACrB,MAAM,cAAc;AAC7B;AAGO,SAAS,yBAAyB,MAAmE;AAC1G,SACE,OAAO,SAAS,YAChB,SAAS,QACT,oBAAoB,QACnB,KAAqC,mBAAmB;AAE7D;AAGO,SAAS,YAAY,OAK1B;AACA,SACE,OAAO,UAAU,YACjB,UAAU,QACV,cAAc,SACd,OAAQ,MAAgC,aAAa;AAEzD;AAGO,SAAS,mBAAmB,OAIjC;AACA,SACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,EAAE,cAAc;AAEpB;AAGO,SAAS,YAAY,OAE1B;AACA,SACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,OAAQ,MAA+B,YAAY;AAEvD;AAGO,SAAS,eAAe,OAAyC;AACtE,MAAI,eAAe,KAAK,GAAG;AACzB,WAAO,MAAM,eAAe;AAAA,EAC9B;AAEA,MAAI,iBAAiB,OAAO;AAC1B,WAAO;AAAA,MACL,SAAS;AAAA,MACT,MAAM,MAAM;AAAA,MACZ,SAAS,MAAM;AAAA,MACf,OAAO,QAAQ,IAAI,aAAa,gBAAgB,MAAM,QAAQ;AAAA,IAChE;AAAA,EACF;AAEA,SAAO;AAAA,IACL,SAAS;AAAA,IACT,SAAS,OAAO,KAAK;AAAA,EACvB;AACF;AAEO,SAAS,iBAAiB,MAAsB;AACrD,MAAI,yBAAyB,IAAI,GAAG;AAClC,WAAO,aAAa,iBAAiB,IAAI;AAAA,EAC3C;AAEA,MAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,aAAa,MAAM;AAClE,UAAM,YAAY;AAMlB,UAAM,QAAQ,IAAI,MAAM,UAAU,WAAW,eAAe;AAC5D,QAAI,UAAU,KAAM,OAAM,OAAO,UAAU;AAC3C,QAAI,UAAU,MAAO,OAAM,QAAQ,UAAU;AAC7C,WAAO;AAAA,EACT;AAEA,SAAO,IAAI,MAAM,eAAe;AAClC;AAGO,SAAS,gBAAgB,OAAwB;AACtD,MAAI,eAAe,KAAK,GAAG;AACzB,WAAO,MAAM;AAAA,EACf;AAEA,MAAI,iBAAiB,OAAO;AAC1B,WAAO,MAAM;AAAA,EACf;AAEA,SAAO;AACT;AAGO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,eAAe,KAAK,GAAG;AACzB,WAAO,MAAM;AAAA,EACf;AAEA,SAAO;AACT;AAMO,SAAS,eAAe,OAAgB,UAAmB,QAAwB;AACxF,QAAM,UAAmC;AAAA,IACvC;AAAA,IACA;AAAA,EACF;AAEA,MAAI,YAAY,KAAK,GAAG;AACtB,UAAM,EAAE,QAAQ,KAAK,IAAI,MAAM;AAC/B,UAAM,YAAY;AAClB,UAAM,cAAc,WAAW,SAAS,WAAW,WAAW,MAAM;AAGpE,UAAM,eAAe,YAAY,SAAS,KAAK,OAAO,YAAY,CAAC,IAAI,QAAQ,MAAM;AACrF,UAAM,kBAAkB,GAAG,WAAW,GAAG,YAAY;AAGrD,YAAQ,UAAU,WAAW,WAAW;AAExC,YAAQ,QAAQ;AAAA,MACd,KAAK;AACH,cAAM,IAAI,gBAAgB,iBAAiB,OAAO;AAAA,MACpD,KAAK;AACH,cAAM,IAAI,UAAU,iBAAiB,OAAO;AAAA,MAC9C,KAAK;AACH,cAAM,IAAI,yBAAyB,iBAAiB,OAAO;AAAA,MAC7D,KAAK;AACH,cAAM,IAAI,mBAAmB,iBAAiB,OAAO;AAAA,MACvD,KAAK;AACH,cAAM,IAAI,cAAc,iBAAiB,OAAO;AAAA,MAClD,KAAK;AACH,cAAM,IAAI,cAAc,iBAAiB,OAAO;AAAA,MAClD,KAAK,KAAK;AACR,cAAM,mBAAmB,MAAM,SAAS,QAAQ,aAAa;AAC7D,cAAM,aAAa,OAAO,qBAAqB,WAAW,SAAS,kBAAkB,EAAE,IAAI;AAC3F,cAAM,IAAI,eAAe,iBAAiB,YAAY,OAAO;AAAA,MAC/D;AAAA,MACA,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AACH,cAAM,IAAI,YAAY,iBAAiB,OAAO;AAAA,MAChD;AACE,cAAM,IAAI,aAAa,iBAAiB,QAAQ,QAAQ,MAAM,IAAI,OAAO;AAAA,IAC7E;AAAA,EACF,WAAW,mBAAmB,KAAK,GAAG;AACpC,UAAM,eAAe,YAAY,SAAS,KAAK,OAAO,YAAY,CAAC,IAAI,QAAQ,MAAM;AACrF,YAAQ,OAAO,MAAM;AAErB,QAAI,MAAM,SAAS,gBAAgB;AACjC,YAAM,IAAI,aAAa,kBAAkB,YAAY,IAAI,OAAO;AAAA,IAClE;AACA,UAAM,IAAI,aAAa,sCAAsC,YAAY,IAAI,OAAO;AAAA,EACtF,WAAW,YAAY,KAAK,GAAG;AAC7B,YAAQ,gBAAgB;AACxB,UAAM,IAAI,aAAa,MAAM,SAAS,KAAK,iBAAiB,OAAO;AAAA,EACrE,OAAO;AACL,YAAQ,gBAAgB;AACxB,UAAM,IAAI,aAAa,iBAAiB,KAAK,iBAAiB,OAAO;AAAA,EACvE;AACF;AAeO,SAAS,wBAAwB,UAA+B,YAAmC;AACxG,QAAM,UAAmC;AAAA,IACvC,MAAM,SAAS,MAAM;AAAA,IACrB,OAAO,SAAS,MAAM;AAAA,EACxB;AAEA,SAAO,IAAI;AAAA,IACT,SAAS,MAAM;AAAA,IACf,cAAc;AAAA,IACd,SAAS,MAAM,QAAQ;AAAA,IACvB;AAAA,EACF;AACF;;;ACrcO,IAAK,aAAL,kBAAKC,gBAAL;AACL,EAAAA,YAAA,SAAM;AACN,EAAAA,YAAA,UAAO;AACP,EAAAA,YAAA,SAAM;AACN,EAAAA,YAAA,YAAS;AACT,EAAAA,YAAA,WAAQ;AACR,EAAAA,YAAA,UAAO;AACP,EAAAA,YAAA,aAAU;AAPA,SAAAA;AAAA,GAAA;AAaL,SAAS,aAAa,QAAsC;AACjE,SAAO,OAAO,OAAO,UAAU,EAAE,SAAS,MAAoB;AAChE;;;ACbO,IAAM,iBAAN,MAAqB;AAAA;AAAA;AAAA;AAAA,EAI1B,aAAa,MACX,UACA,cACY;AAEZ,UAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,QAAI,kBAAkB,OAAO,SAAS,WAAW,KAAK;AACpD,aAAO;AAAA,IACT;AAGA,QAAI,cAAc;AAChB,cAAQ,cAAc;AAAA,QACpB,KAAK;AACH,iBAAO,MAAM,SAAS,KAAK;AAAA,QAC7B,KAAK;AACH,iBAAO,MAAM,SAAS,KAAK;AAAA,QAC7B,KAAK;AACH,iBAAO,MAAM,SAAS,KAAK;AAAA,QAC7B,KAAK;AACH,iBAAO,MAAM,SAAS,YAAY;AAAA,QACpC,KAAK;AACH,cAAI,CAAC,SAAS,MAAM;AAClB,kBAAM,IAAI,MAAM,+BAA+B;AAAA,UACjD;AACA,iBAAO,SAAS;AAAA,QAClB,SAAS;AAEP,gBAAM,cAAqB;AAC3B,gBAAM,IAAI,MAAM,0BAA0B,OAAO,WAAW,CAAC,EAAE;AAAA,QACjE;AAAA,MACF;AAAA,IACF;AAGA,UAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;AAE5D,QAAI,YAAY,SAAS,kBAAkB,GAAG;AAC5C,aAAO,MAAM,SAAS,KAAK;AAAA,IAC7B;AAEA,QAAI,YAAY,SAAS,OAAO,KAAK,YAAY,SAAS,iBAAiB,GAAG;AAC5E,aAAO,MAAM,SAAS,KAAK;AAAA,IAC7B;AAEA,QAAI,YAAY,SAAS,0BAA0B,KAC/C,YAAY,SAAS,QAAQ,KAC7B,YAAY,SAAS,QAAQ,KAC7B,YAAY,SAAS,QAAQ,GAAG;AAClC,aAAO,MAAM,SAAS,KAAK;AAAA,IAC7B;AAGA,WAAO,MAAM,SAAS,KAAK;AAAA,EAC7B;AAAA;AAAA;AAAA;AAAA,EAKA,OAAO,iBAAiB,MAAwC;AAE9D,UAAM,EAAE,cAAc,SAAS,UAAU,GAAG,aAAa,IAAI;AAC7D,WAAO;AAAA,EACT;AACF;;;AClEO,IAAM,eAAe;AAAA,EAC1B,cAAc;AAAA,EACd,eAAe;AAAA,EACf,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,kBAAkB;AAAA,EAClB,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,eAAe;AACjB;AAOO,IAAM,gBAAgB;AAAA,EAC3B,MAAM;AAAA,EACN,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ,aAAa;AACf;AAOO,IAAM,cAAc;AAAA;AAAA,EAEzB,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,YAAY;AAAA;AAAA,EAGZ,aAAa;AAAA,EACb,cAAc;AAAA,EACd,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,mBAAmB;AAAA,EACnB,cAAc;AAAA;AAAA;AAAA,EAGd,uBAAuB;AAAA,EACvB,gBAAgB;AAAA;AAAA,EAChB,aAAa;AAAA,EACb,qBAAqB;AAAA,EACrB,iBAAiB;AACnB;AAOO,IAAM,cAAc;AAAA,EACzB,oBAAoB;AAAA,EACpB,SAAS;AAAA,EACT,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,oBAAoB;AAAA,EACpB,gBAAgB;AAClB;AAOO,IAAM,WAAW;AAAA,EACtB,iBAAiB;AAAA;AAAA,EACjB,eAAe;AAAA;AAAA,EACf,cAAc;AAAA;AAAA,EACd,WAAW;AAAA;AACb;AAOO,IAAM,eAAe;AAAA,EAC1B,qBAAqB;AAAA,EACrB,eAAe;AAAA;AAAA,EACf,WAAW;AAAA;AAAA,EACX,gBAAgB;AAClB;;;AC5FO,IAAK,qBAAL,kBAAKC,wBAAL;AACL,EAAAA,oBAAA,kBAAe;AACf,EAAAA,oBAAA,gBAAa;AACb,EAAAA,oBAAA,eAAY;AACZ,EAAAA,oBAAA,mBAAgB;AAChB,EAAAA,oBAAA,kBAAe;AALL,SAAAA;AAAA,GAAA;AAWL,IAAK,kBAAL,kBAAKC,qBAAL;AACL,EAAAA,kCAAA,WAAQ,KAAR;AACA,EAAAA,kCAAA,WAAQ,KAAR;AACA,EAAAA,kCAAA,iBAAc,KAAd;AACA,EAAAA,kCAAA,aAAU,KAAV;AACA,EAAAA,kCAAA,WAAQ,KAAR;AACA,EAAAA,kCAAA,cAAW,KAAX;AACA,EAAAA,kCAAA,UAAO,KAAP;AAPU,SAAAA;AAAA,GAAA;AAaL,IAAK,oBAAL,kBAAKC,uBAAL;AACL,EAAAA,sCAAA,UAAO,KAAP;AACA,EAAAA,sCAAA,gBAAa,KAAb;AACA,EAAAA,sCAAA,sBAAmB,KAAnB;AACA,EAAAA,sCAAA,iBAAc,KAAd;AAJU,SAAAA;AAAA,GAAA;AAUL,IAAM,oBACX,qBACA,2BACA;AAKK,IAAK,sBAAL,kBAAKC,yBAAL;AAIL,EAAAA,qBAAA,UAAO;AAIP,EAAAA,qBAAA,iBAAc;AARJ,SAAAA;AAAA,GAAA;;;AC7CZ,cAAyB;AAYzB,IAAI;AAKJ,eAAe,0BAAwC;AACrD,MAAI,CAAC,wBAAwB;AAC3B,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,qCAAqC;AAClE,+BAAyB,QAAQ;AACjC,aAAO,QAAQ;AAAA,IACjB,SAAS,OAAO;AACd,cAAQ,KAAK,mDAAmD,KAAK;AACrE,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AA+BO,IAAe,wBAAf,MAAqC;AAAA,EAChC;AAAA,EACS;AAAA,EACT;AAAA,EACF;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EAOnB,YAAY,QAA2B;AACrC,SAAK,SAAS;AAAA,MACZ,GAAG;AAAA,MACH,SAAS,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAAA,IAC3C;AAGA,SAAK,yBAAyB,IAAI,QAAQ,CAAC,SAAS,WAAW;AAC7D,WAAK,yBAAyB;AAC9B,WAAK,wBAAwB;AAAA,IAC/B,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,cAAuB;AACzB,WAAO,KAAK,YAAY,UAAkB,2BAAmB;AAAA,EAC/D;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,QAA4B;AAC9B,QAAI,CAAC,KAAK,YAAY;AACpB;AAAA,IACF;AAEA,YAAQ,KAAK,WAAW,OAAO;AAAA,MAC7B,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF;AACE;AAAA,IACJ;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,gBAAgD;AAC9D,QAAI,KAAK,YAAY;AACnB,aAAO,KAAK;AAAA,IACd;AAEA,UAAM,SAAS,GAAG,KAAK,OAAO,OAAO,GAAG,KAAK,OAAO;AAGpD,UAAM,oBAAoD;AAAA,MACxD,oBAAoB,KAAK,OAAO,SAAS,uBAAuB,MAAM,KAAK,OAAO,KAAK;AAAA,MACvF,WAAW,KAAK,iBAAiB,KAAK,OAAO,SAAS,aAAa,iBAAiB;AAAA,MACpF,SAAS,KAAK,aAAa;AAAA,MAC3B,iBAAiB;AAAA,IACnB;AAGA,UAAM,UAAU,IAAY,6BAAqB,EAC9C,QAAQ,QAAQ,iBAAiB,EACjC,uBAAuB,KAAK,OAAO,SAAS,qBAAqB,CAAC,GAAG,KAAM,KAAO,GAAK,CAAC;AAG3F,QAAI,KAAK,OAAO,SAAS,eAAe;AACtC,cAAQ,kBAAkB,KAAK,OAAO,QAAQ,aAAa;AAAA,IAC7D;AAEA,QAAI,KAAK,OAAO,SAAS,mBAAmB;AAC1C,cAAQ,sBAAsB,KAAK,OAAO,QAAQ,iBAAiB;AAAA,IACrE;AAGA,UAAM,WAAW,KAAK,YAAY,KAAK,OAAO,SAAS,+BAAuC;AAC9F,YAAQ,iBAAiB,QAAQ;AAGjC,UAAM,eAAe,KAAK,OAAO,SAAS;AAC1C,QAAI,kDAAkD;AACpD,UAAI;AACF,cAAM,sBAAsB,MAAM,wBAAwB;AAC1D,YAAI,qBAAqB;AACvB,kBAAQ,gBAAgB,IAAI,oBAAoB,CAAC;AACjD,kBAAQ,KAAK,mDAAmD;AAAA,QAClE;AAAA,MACF,SAAS,OAAO;AACd,gBAAQ,MAAM,8DAA8D,KAAK;AAAA,MAEnF;AAAA,IACF;AAEA,SAAK,aAAa,QAAQ,MAAM;AAGhC,SAAK,WAAW,QAAQ,OAAO,UAAU;AACvC,UAAI,KAAK,gBAAgB;AACvB,cAAM,KAAK,eAAe,KAAK;AAAA,MACjC;AAAA,IACF,CAAC;AAED,SAAK,WAAW,eAAe,OAAO,UAAU;AAC9C,UAAI,KAAK,gBAAgB;AACvB,cAAM,KAAK,eAAe,KAAK;AAAA,MACjC;AAAA,IACF,CAAC;AAED,SAAK,WAAW,cAAc,OAAO,iBAAiB;AACpD,UAAI,KAAK,eAAe;AACtB,cAAM,KAAK,cAAc,YAAY;AAAA,MACvC;AAAA,IACF,CAAC;AAGD,SAAK,qBAAqB,KAAK,UAAU;AAEzC,QAAI;AACF,YAAM,KAAK,WAAW,MAAM;AAE5B,UAAI,KAAK,wBAAwB;AAC/B,aAAK,uBAAuB;AAAA,MAC9B;AAEA,UAAI,KAAK,aAAa;AACpB,cAAM,KAAK,YAAY;AAAA,MACzB;AAAA,IACF,SAAS,OAAO;AACd,UAAI,KAAK,uBAAuB;AAC9B,aAAK,sBAAsB,KAAc;AAAA,MAC3C;AACA,YAAM;AAAA,IACR;AAEA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAUU,iBAAiB,WAAyD;AAClF,QAAI,SAAiB,0BAAkB;AAEvC,QAAI,gCAA0C;AAC5C,gBAAkB,0BAAkB;AAAA,IACtC;AACA,QAAI,sCAAgD;AAClD,gBAAkB,0BAAkB;AAAA,IACtC;AACA,QAAI,iCAA2C;AAC7C,gBAAkB,0BAAkB;AAAA,IACtC;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKU,YAAY,OAA0C;AAC9D,YAAQ,OAAO;AAAA,MACb;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,IAC5B;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,eAAuC;AAC7C,UAAM,UAAkC;AAAA,MACtC,cAAc,KAAK,OAAO,aAAa;AAAA,MACvC,GAAG,KAAK,OAAO,SAAS;AAAA,IAC1B;AAGA,QAAI,KAAK,OAAO,KAAK,aAAa,YAAY,KAAK,OAAO,KAAK,mBAAmB;AAChF,aAAO,OAAO,SAAS,KAAK,OAAO,KAAK,iBAAiB;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAa,eAA8B;AACzC,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,OAAiB,eAAuB,MAA6B;AACnF,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI,MAAM,8BAA8B;AAAA,IAChD;AAEA,UAAM,aAAa,MAAM,KAAK,cAAc;AAE5C,QAAI;AACF,aAAO,MAAM,WAAW,OAAU,YAAY,GAAG,IAAI;AAAA,IACvD,SAAS,OAAO;AACd,YAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC1E,YAAM,IAAI,MAAM,4BAA4B,UAAU,KAAK,YAAY,EAAE;AAAA,IAC3E;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,KAAK,eAAuB,MAAgC;AAC1E,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI,MAAM,8BAA8B;AAAA,IAChD;AAEA,UAAM,aAAa,MAAM,KAAK,cAAc;AAE5C,QAAI;AACF,YAAM,WAAW,KAAK,YAAY,GAAG,IAAI;AAAA,IAC3C,SAAS,OAAO;AACd,YAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC1E,YAAM,IAAI,MAAM,0BAA0B,UAAU,KAAK,YAAY,EAAE;AAAA,IACzE;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAa,aAA4B;AACvC,QAAI,KAAK,cAAc,KAAK,WAAW,UAAkB,2BAAmB,cAAc;AACxF,YAAM,KAAK,WAAW,KAAK;AAC3B,WAAK,aAAa;AAGlB,WAAK,yBAAyB,IAAI,QAAQ,CAAC,SAAS,WAAW;AAC7D,aAAK,yBAAyB;AAC9B,aAAK,wBAAwB;AAAA,MAC/B,CAAC;AAAA,IACH;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAa,UAAyB;AACpC,SAAK,WAAW;AAChB,UAAM,KAAK,WAAW;AACtB,SAAK,yBAAyB;AAC9B,SAAK,wBAAwB;AAAA,EAC/B;AACF;;;AChSO,IAAM,YAAN,cAAwB,MAAM;AAAA,EAC5B;AAAA,EACA;AAAA,EAKA;AAAA,EACA;AAAA,EAMP,YAAY,SAAiB,MAAe;AAC1C,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AAAA,EACd;AACF;;;AC5EO,IAAK,oBAAL,kBAAKC,uBAAL;AAEL,EAAAA,mBAAA,iBAAc;AAEd,EAAAA,mBAAA,yBAAsB;AAEtB,EAAAA,mBAAA,mBAAgB;AANN,SAAAA;AAAA,GAAA;AAkEL,SAAS,oBACd,UACA,SACQ;AACR,UAAQ,SAAS,MAAM;AAAA,IACrB,KAAK;AACH,aAAO,SAAS;AAAA,IAElB,KAAK,iDAAuC;AAC1C,YAAM,QAAQ,KAAK;AAAA,QACjB,SAAS,iBAAiB,KAAK,IAAI,SAAS,QAAQ,UAAU,CAAC;AAAA,QAC/D,SAAS;AAAA,MACX;AACA,UAAI,SAAS,QAAQ;AAEnB,eAAO,QAAQ,KAAK,OAAO,IAAI;AAAA,MACjC;AACA,aAAO;AAAA,IACT;AAAA,IAEA,KAAK,qCAAiC;AAEpC,YAAM,QAAQ,KAAK,IAAI,UAAU,GAAG,SAAS,OAAO,SAAS,CAAC;AAC9D,aAAO,SAAS,OAAO,KAAK;AAAA,IAC9B;AAAA,EACF;AACF;AAOO,SAAS,cAAc,UAAiC;AAC7D,UAAQ,SAAS,MAAM;AAAA,IACrB,KAAK;AAAA,IACL,KAAK;AACH,aAAO,SAAS;AAAA,IAClB,KAAK;AACH,aAAO,SAAS,OAAO;AAAA,EAC3B;AACF;AAQO,SAAS,wBACd,UACA,OACS;AACT,MAAI,SAAS,gBAAgB;AAC3B,WAAO,SAAS,eAAe,KAAK;AAAA,EACtC;AAEA,SAAO;AACT;AAKO,IAAM,2BAA2B;AAAA;AAAA,EAEtC,SAAS;AAAA,IACP,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR,QAAQ;AAAA,EACV;AAAA;AAAA,EAGA,OAAO;AAAA,IACL,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,SAAS;AAAA,EACX;AACF;;;ACjHO,IAAe,gBAAf,MAA6B;AAAA;AAAA,EAEf;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EAEnB,YAAY,QAA6B;AACvC,SAAK,UAAU,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAC/C,SAAK,UAAU,OAAO,WAAW;AACjC,SAAK,iBAAiB,OAAO,kBAAkB,CAAC;AAChD,SAAK,gBAAgB,OAAO,iBAAiB,KAAK,wBAAwB;AAC1E,SAAK,QAAQ,OAAO,SAAS;AAE7B,SAAK,UAAU,OAAO;AACtB,SAAK,YAAY,OAAO;AACxB,SAAK,aAAa,OAAO;AACzB,SAAK,SAAS,OAAO;AACrB,SAAK,QAAQ,OAAO;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiCA,MAAgB,oBAAoB,UAAoC;AACtE,QAAI;AACJ,QAAI;AACF,YAAM,cAAc,SAAS,QAAQ,IAAI,cAAc;AACvD,UAAI,aAAa,SAAS,kBAAkB,GAAG;AAC7C,oBAAY,MAAM,SAAS,KAAK;AAAA,MAClC;AAAA,IACF,QAAQ;AACN,kBAAY,CAAC;AAAA,IACf;AAGA,WAAO,IAAI;AAAA,MACT,QAAQ,SAAS,MAAM,KAAK,SAAS,UAAU;AAAA,MAC/C,SAAS;AAAA,MACT,QAAQ,SAAS,MAAM;AAAA,MACvB,EAAE,MAAM,UAAU;AAAA,IACpB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUU,YAAY,OAAgB,SAA0B;AAC9D,UAAM,aAAa,cAAc,KAAK,aAAa;AACnD,QAAI,UAAU,WAAY,QAAO;AAGjC,QAAI,KAAK,cAAc,gBAAgB;AACrC,aAAO,KAAK,cAAc,eAAe,KAAK;AAAA,IAChD;AAGA,QAAI,iBAAiB,cAAc;AAEjC,aAAO,MAAM,eAAe,OAAO,MAAM,cAAc;AAAA,IACzD;AAEA,QAAI,iBAAiB,OAAO;AAE1B,aACE,MAAM,SAAS,gBACf,MAAM,QAAQ,SAAS,SAAS,KAChC,MAAM,QAAQ,SAAS,OAAO;AAAA,IAElC;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWU,cAAc,QAAiB,SAAyB;AAChE,WAAO,oBAAoB,KAAK,eAAe,OAAO;AAAA,EACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAgB,QACd,KACA,UAAyE,CAAC,GACtD;AACpB,UAAM,UAAU,KAAK,SAAS,GAAG;AACjC,UAAM,aAAa,IAAI,gBAAgB;AAEvC,UAAM,YAAY,QAAQ,WAAW,KAAK;AAC1C,UAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAEhE,QAAI;AACF,YAAM,cAAiC;AAAA,QACrC,QAAQ,QAAQ;AAAA,QAChB,KAAK;AAAA,QACL,SAAS,KAAK,aAAa,QAAQ,OAAO;AAAA,QAC1C,MAAM,QAAQ;AAAA,MAChB;AAGA,UAAI,KAAK,WAAW;AAClB,cAAM,KAAK,UAAU,WAAW;AAAA,MAClC;AAEA,WAAK,IAAI,SAAS,gBAAgB,YAAY,MAAM,IAAI,YAAY,GAAG,EAAE;AAEzE,YAAM,WAAW,MAAM,KAAK;AAAA,QAC1B;AAAA,QACA;AAAA,UACE,QAAQ,YAAY;AAAA,UACpB,SAAS,YAAY;AAAA,UACrB,MAAM,QAAQ,OAAO,KAAK,UAAU,QAAQ,IAAI,IAAI;AAAA,UACpD,QAAQ,QAAQ,UAAU,WAAW;AAAA,UACrC,cAAc,QAAQ;AAAA,UACtB,SAAS;AAAA,QACX;AAAA,MACF;AAEA,aAAO;AAAA,IACT,UAAE;AACA,mBAAa,SAAS;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,IACd,KACA,SACoB;AACpB,WAAO,KAAK,QAAmB,KAAK,EAAE,GAAG,SAAS,wBAAuB,CAAC;AAAA,EAC5E;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,KACd,KACA,MACA,SACoB;AACpB,WAAO,KAAK,QAA6B,KAAK;AAAA,MAC5C,GAAG;AAAA,MACH;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,IACd,KACA,MACA,SACoB;AACpB,WAAO,KAAK,QAA6B,KAAK;AAAA,MAC5C,GAAG;AAAA,MACH;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,MACd,KACA,MACA,SACoB;AACpB,WAAO,KAAK,QAA6B,KAAK;AAAA,MAC5C,GAAG;AAAA,MACH;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,OACd,KACA,SACoB;AACpB,WAAO,KAAK,QAAmB,KAAK,EAAE,GAAG,SAAS,8BAA0B,CAAC;AAAA,EAC/E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,iBACZ,KACA,MACA,UAAkB,GACE;AACpB,QAAI;AACF,YAAM,WAAW,MAAM,MAAM,KAAK,eAAe,iBAAiB,IAAI,CAAC;AAEvE,WAAK,IAAI,SAAS,iBAAiB,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE;AAG3E,YAAM,UAAkC,CAAC;AACzC,eAAS,QAAQ,QAAQ,CAAC,OAAO,QAAQ;AACvC,gBAAQ,GAAG,IAAI;AAAA,MACjB,CAAC;AAGD,UAAI,KAAK,YAAY;AACnB,cAAM,eAA6B;AAAA,UACjC,QAAQ,SAAS;AAAA,UACjB,YAAY,SAAS;AAAA,UACrB;AAAA,UACA,MAAM;AAAA,UACN,QAAQ;AAAA,YACN;AAAA,YACA,QAAS,KAAK;AAAA,YACd,SAAU,KAAK,WAAsC,CAAC;AAAA,UACxD;AAAA,QACF;AACA,cAAM,KAAK,WAAW,YAAY;AAAA,MACpC;AAEA,UAAI,CAAC,SAAS,IAAI;AAChB,cAAM,QAAQ,MAAM,KAAK,oBAAoB,QAAQ;AACrD,cAAM;AAAA,MACR;AAGA,YAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,UAAI,kBAAkB,OAAO,SAAS,WAAW,KAAK;AACpD,eAAO;AAAA,MACT;AAEA,aAAO,MAAM,eAAe,MAAiB,UAAU,KAAK,YAAY;AAAA,IAC1E,SAAS,OAAO;AACd,UAAI,KAAK,YAAY,OAAO,OAAO,GAAG;AACpC,cAAM,QAAQ,KAAK,cAAc,OAAO,OAAO;AAC/C,aAAK,IAAI,SAAS,6BAA6B,UAAU,CAAC,WAAW,KAAK,IAAI;AAE9E,cAAM,KAAK,MAAM,KAAK;AACtB,eAAO,KAAK,iBAA4B,KAAK,MAAM,UAAU,CAAC;AAAA,MAChE;AAGA,UAAI,KAAK,WAAW,iBAAiB,OAAO;AAC1C,aAAK,QAAQ,KAAK;AAAA,MACpB;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,SAAS,MAAsB;AAErC,QAAI,KAAK,WAAW,SAAS,KAAK,KAAK,WAAW,UAAU,GAAG;AAC7D,aAAO;AAAA,IACT;AAGA,UAAM,YAAY,KAAK,WAAW,GAAG,IAAI,OAAO,IAAI,IAAI;AACxD,WAAO,GAAG,KAAK,OAAO,GAAG,SAAS;AAAA,EACpC;AAAA;AAAA;AAAA;AAAA,EAKQ,aAAa,mBAAoE;AACvF,WAAO;AAAA,MACL,CAAC,aAAa,YAAY,GAAG,cAAc;AAAA,MAC3C,GAAG,KAAK,eAAe;AAAA,MACvB,GAAG,KAAK;AAAA,MACR,GAAG;AAAA,IACL;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKU,IACR,OACA,YACG,MACG;AACN,QAAI,KAAK,SAAS,KAAK,GAAG;AACxB,WAAK,OAAO,KAAK,EAAE,SAAS,GAAG,IAAI;AAAA,IACrC,WAAW,KAAK,SAAS,UAAU,SAAS;AAC1C,cAAQ,KAAK,SAAS,OAAO,IAAI,GAAG,IAAI;AAAA,IAC1C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,MAAM,IAA2B;AACvC,WAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,EACzD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAgB,aAAgB,KAAgC;AAC9D,QAAI,CAAC,KAAK,MAAO,QAAO;AAExB,QAAI;AACF,YAAM,SAAS,MAAM,KAAK,MAAM,IAAO,GAAG;AAC1C,UAAI,QAAQ;AACV,aAAK,IAAI,SAAS,sBAAsB,GAAG,EAAE;AAC7C,eAAO;AAAA,MACT;AAAA,IACF,SAAS,OAAO;AACd,WAAK,IAAI,SAAS,oBAAoB,KAAK;AAAA,IAC7C;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAgB,SAAS,KAAa,OAAgB,KAA6B;AACjF,QAAI,CAAC,KAAK,MAAO;AAEjB,QAAI;AACF,YAAM,KAAK,MAAM,IAAI,KAAK,OAAO,GAAG;AACpC,WAAK,IAAI,SAAS,sBAAsB,GAAG,EAAE;AAAA,IAC/C,SAAS,OAAO;AACd,WAAK,IAAI,SAAS,oBAAoB,KAAK;AAAA,IAC7C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAgB,UACd,UACA,IACA,KACY;AACZ,UAAM,SAAS,MAAM,KAAK,aAAgB,QAAQ;AAClD,QAAI,WAAW,MAAM;AACnB,aAAO;AAAA,IACT;AAEA,UAAM,SAAS,MAAM,GAAG;AACxB,UAAM,KAAK,SAAS,UAAU,QAAQ,GAAG;AAEzC,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKU,YACR,aACG,aACK;AACR,UAAM,QAAQ,YACX,OAAO,CAAC,OAAO,OAAO,MAAS,EAC/B,IAAI,CAAC,OAAQ,OAAO,OAAO,WAAW,KAAK,UAAU,EAAE,IAAI,OAAO,EAAE,CAAE;AACzE,WAAO,GAAG,QAAQ,IAAI,MAAM,KAAK,GAAG,CAAC;AAAA,EACvC;AACF;;;ACndO,IAAK,eAAL,kBAAKC,kBAAL;AAEL,EAAAA,cAAA,YAAS;AAET,EAAAA,cAAA,UAAO;AAEP,EAAAA,cAAA,eAAY;AANF,SAAAA;AAAA,GAAA;;;ACAL,IAAM,0BAAN,cAAsC,aAAa;AAAA;AAAA,EAExC;AAAA;AAAA,EAGA;AAAA;AAAA,EAGA;AAAA,EAEhB,YACE,SACA,OACA,mBACA;AACA,UAAM,SAAS,KAAK,wBAAwB;AAAA,MAC1C,cAAc,MAAM;AAAA,MACpB;AAAA,MACA,qBAAqB,MAAM;AAAA,MAC3B,eAAe,MAAM;AAAA,IACvB,CAAC;AAED,SAAK,eAAe,MAAM;AAC1B,SAAK,oBAAoB;AACzB,SAAK,QAAQ;AAAA,EACf;AACF;AAKO,SAAS,0BAA0B,OAAkD;AAC1F,SAAO,iBAAiB;AAC1B;;;AC3BA,IAAM,iBAA+E;AAAA,EACnF,kBAAkB;AAAA,EAClB,iBAAiB;AAAA;AAAA,EACjB,gBAAgB;AAAA;AAAA,EAChB,kBAAkB;AAAA,EAClB,eAAe;AACjB;AAeO,IAAM,iBAAN,MAAqB;AAAA,EACT;AAAA,EAEA;AAAA;AAAA,EAGT;AAAA,EACA,WAA4B,CAAC;AAAA,EAC7B,oBAA4B;AAAA;AAAA,EAG5B,gBAAwB;AAAA,EACxB,iBAAyB;AAAA,EACzB,mBAA2B;AAAA,EAC3B,kBAAiC;AAAA,EACjC,gBAA+B;AAAA,EAC/B,gBAA+B;AAAA,EAEvC,YACE,SAA+B,CAAC,GAChC,YAAqC,CAAC,GACtC;AACA,SAAK,SAAS;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,IACL;AACA,SAAK,YAAY;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAyB;AAEvB,QAAI,KAAK,+BAA+B,KAAK,oBAAoB,MAAM;AACrE,YAAM,UAAU,KAAK,IAAI,IAAI,KAAK;AAClC,UAAI,WAAW,KAAK,OAAO,gBAAgB;AACzC,aAAK,wCAAmC;AAAA,MAC1C;AAAA,IACF;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAKA,WAAgC;AAC9B,UAAM,eAAe,KAAK,SAAS;AACnC,WAAO;AAAA,MACL,OAAO;AAAA,MACP,qBAAqB,KAAK,+BAA+B;AAAA,MACzD,eAAe,KAAK;AAAA,MACpB,gBAAgB,KAAK;AAAA,MACrB,iBAAiB,KAAK;AAAA,MACtB,mBAAmB,KAAK,2BAA2B;AAAA,MACnD,eAAe,KAAK;AAAA,MACpB,eAAe,KAAK;AAAA,MACpB,kBAAkB,KAAK;AAAA,IACzB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,aAAsB;AACpB,UAAM,QAAQ,KAAK,SAAS;AAC5B,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAkB;AAChB,UAAM,QAAQ,KAAK,SAAS;AAC5B,QAAI,6BAA6B;AAC/B,WAAK;AACL,YAAM,QAAQ,KAAK,SAAS;AAC5B,WAAK,UAAU,aAAa,KAAK;AAEjC,YAAM,IAAI;AAAA,QACR,yCAAyC,KAAK,MAAM,MAAM,qBAAqB,KAAK,GAAI,CAAC;AAAA,QACzF;AAAA,QACA,MAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,gBAAsB;AACpB,SAAK;AACL,SAAK,gBAAgB,KAAK,IAAI;AAE9B,UAAM,eAAe,KAAK,SAAS;AAEnC,QAAI,8CAAyC;AAC3C,WAAK;AACL,WAAK,IAAI,SAAS,qBAAqB,KAAK,iBAAiB,IAAI,KAAK,OAAO,gBAAgB,EAAE;AAE/F,UAAI,KAAK,qBAAqB,KAAK,OAAO,kBAAkB;AAC1D,aAAK,kCAAgC;AAAA,MACvC;AAAA,IACF,WAAW,wCAAsC;AAE/C,WAAK,WAAW,CAAC;AAAA,IACnB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,cAAc,OAAsB;AAElC,QAAI,KAAK,OAAO,wBAAwB,CAAC,KAAK,OAAO,qBAAqB,KAAK,GAAG;AAChF,WAAK,IAAI,SAAS,+CAA+C;AACjE;AAAA,IACF;AAEA,UAAM,MAAM,KAAK,IAAI;AACrB,SAAK;AACL,SAAK,gBAAgB;AAErB,UAAM,eAAe,KAAK,SAAS;AAEnC,QAAI,8CAAyC;AAE3C,WAAK,IAAI,QAAQ,+CAA+C;AAChE,WAAK,gCAAgC,KAAK;AAC1C;AAAA,IACF;AAEA,QAAI,wCAAsC;AAExC,WAAK,SAAS,KAAK,EAAE,WAAW,KAAK,MAAM,CAAC;AAG5C,WAAK,iBAAiB;AAGtB,YAAM,sBAAsB,KAAK,+BAA+B;AAChE,WAAK,IAAI,SAAS,yBAAyB,mBAAmB,IAAI,KAAK,OAAO,gBAAgB,EAAE;AAEhG,UAAI,uBAAuB,KAAK,OAAO,kBAAkB;AACvD,aAAK,gCAAgC,KAAK;AAAA,MAC5C;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAc;AACZ,SAAK,IAAI,QAAQ,wBAAwB;AACzC,SAAK,kCAAgC;AACrC,SAAK,WAAW,CAAC;AACjB,SAAK,gBAAgB;AACrB,SAAK,iBAAiB;AACtB,SAAK,mBAAmB;AAAA,EAC1B;AAAA;AAAA,EAIQ,aAAa,UAAwB,cAA8B;AACzE,UAAM,WAAW,KAAK;AACtB,QAAI,aAAa,SAAU;AAE3B,SAAK,QAAQ;AACb,UAAM,QAAQ,KAAK,SAAS;AAE5B,SAAK,IAAI,QAAQ,yBAAyB,QAAQ,OAAO,QAAQ,EAAE;AAEnE,YAAQ,UAAU;AAAA,MAChB;AACE,aAAK,kBAAkB,KAAK,IAAI;AAChC,aAAK,oBAAoB;AACzB,aAAK,UAAU,SAAS,OAAO,YAAY;AAC3C;AAAA,MAEF;AACE,aAAK,oBAAoB;AACzB,aAAK,UAAU,aAAa,KAAK;AACjC;AAAA,MAEF;AACE,aAAK,kBAAkB;AACvB,aAAK,WAAW,CAAC;AACjB,aAAK,oBAAoB;AACzB,aAAK,UAAU,UAAU,KAAK;AAC9B;AAAA,IACJ;AAEA,SAAK,UAAU,gBAAgB,UAAU,UAAU,KAAK;AAAA,EAC1D;AAAA,EAEQ,mBAAyB;AAC/B,UAAM,SAAS,KAAK,IAAI,IAAI,KAAK,OAAO;AACxC,SAAK,WAAW,KAAK,SAAS,OAAO,OAAK,EAAE,aAAa,MAAM;AAAA,EACjE;AAAA,EAEQ,iCAAyC;AAC/C,SAAK,iBAAiB;AACtB,WAAO,KAAK,SAAS;AAAA,EACvB;AAAA,EAEQ,6BAA4C;AAClD,QAAI,KAAK,+BAA+B,KAAK,oBAAoB,MAAM;AACrE,aAAO;AAAA,IACT;AAEA,UAAM,UAAU,KAAK,IAAI,IAAI,KAAK;AAClC,UAAM,YAAY,KAAK,OAAO,iBAAiB;AAC/C,WAAO,YAAY,IAAI,YAAY;AAAA,EACrC;AAAA,EAEQ,IAAI,QAA6C,SAAuB;AAC9E,QAAI,KAAK,OAAO,eAAe;AAC7B,cAAQ,KAAK,oBAAoB,OAAO,EAAE;AAAA,IAC5C;AAAA,EACF;AACF;","names":["ModelCapability","HttpMethod","HubConnectionState","SignalRLogLevel","HttpTransportType","SignalRProtocolType","RetryStrategyType","CircuitState"]} \ No newline at end of file diff --git a/SDKs/Node/Common/dist/index.mjs b/SDKs/Node/Common/dist/index.mjs deleted file mode 100644 index f57b4e7c7..000000000 --- a/SDKs/Node/Common/dist/index.mjs +++ /dev/null @@ -1,1454 +0,0 @@ -// src/types/capabilities.ts -var ModelCapability = /* @__PURE__ */ ((ModelCapability2) => { - ModelCapability2["CHAT"] = "chat"; - ModelCapability2["VISION"] = "vision"; - ModelCapability2["IMAGE_GENERATION"] = "image-generation"; - ModelCapability2["IMAGE_EDIT"] = "image-edit"; - ModelCapability2["IMAGE_VARIATION"] = "image-variation"; - ModelCapability2["AUDIO_TRANSCRIPTION"] = "audio-transcription"; - ModelCapability2["TEXT_TO_SPEECH"] = "text-to-speech"; - ModelCapability2["REALTIME_AUDIO"] = "realtime-audio"; - ModelCapability2["EMBEDDINGS"] = "embeddings"; - ModelCapability2["VIDEO_GENERATION"] = "video-generation"; - return ModelCapability2; -})(ModelCapability || {}); -function getCapabilityDisplayName(capability) { - switch (capability) { - case "chat" /* CHAT */: - return "Chat Completion"; - case "vision" /* VISION */: - return "Vision (Image Understanding)"; - case "image-generation" /* IMAGE_GENERATION */: - return "Image Generation"; - case "image-edit" /* IMAGE_EDIT */: - return "Image Editing"; - case "image-variation" /* IMAGE_VARIATION */: - return "Image Variation"; - case "audio-transcription" /* AUDIO_TRANSCRIPTION */: - return "Audio Transcription"; - case "text-to-speech" /* TEXT_TO_SPEECH */: - return "Text-to-Speech"; - case "realtime-audio" /* REALTIME_AUDIO */: - return "Realtime Audio"; - case "embeddings" /* EMBEDDINGS */: - return "Embeddings"; - case "video-generation" /* VIDEO_GENERATION */: - return "Video Generation"; - default: - return capability; - } -} -function getCapabilityCategory(capability) { - switch (capability) { - case "chat" /* CHAT */: - case "embeddings" /* EMBEDDINGS */: - return "text"; - case "vision" /* VISION */: - case "image-generation" /* IMAGE_GENERATION */: - case "image-edit" /* IMAGE_EDIT */: - case "image-variation" /* IMAGE_VARIATION */: - return "vision"; - case "audio-transcription" /* AUDIO_TRANSCRIPTION */: - case "text-to-speech" /* TEXT_TO_SPEECH */: - case "realtime-audio" /* REALTIME_AUDIO */: - return "audio"; - case "video-generation" /* VIDEO_GENERATION */: - return "video"; - default: - return "text"; - } -} - -// src/errors/index.ts -var ConduitError = class _ConduitError extends Error { - statusCode; - code; - context; - // Admin SDK specific fields - details; - endpoint; - method; - // Core SDK specific fields - type; - param; - constructor(message, statusCode = 500, code = "INTERNAL_ERROR", context) { - super(message); - this.name = this.constructor.name; - this.statusCode = statusCode; - this.code = code; - this.context = context; - if (context) { - this.details = context.details; - this.endpoint = context.endpoint; - this.method = context.method; - this.type = context.type; - this.param = context.param; - } - Object.setPrototypeOf(this, new.target.prototype); - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } - toJSON() { - return { - name: this.name, - message: this.message, - statusCode: this.statusCode, - code: this.code, - context: this.context, - details: this.details, - endpoint: this.endpoint, - method: this.method, - type: this.type, - param: this.param, - timestamp: (/* @__PURE__ */ new Date()).toISOString() - }; - } - // Helper method for Next.js serialization - toSerializable() { - return { - isConduitError: true, - ...this.toJSON() - }; - } - // Static method to reconstruct from serialized error - static fromSerializable(data) { - if (!data || typeof data !== "object" || !("isConduitError" in data) || !data.isConduitError) { - throw new Error("Invalid serialized ConduitError"); - } - const errorData = data; - const error = new _ConduitError( - errorData.message, - errorData.statusCode, - errorData.code, - errorData.context - ); - if (errorData.details !== void 0) error.details = errorData.details; - if (errorData.endpoint !== void 0) error.endpoint = errorData.endpoint; - if (errorData.method !== void 0) error.method = errorData.method; - if (errorData.type !== void 0) error.type = errorData.type; - if (errorData.param !== void 0) error.param = errorData.param; - return error; - } -}; -var AuthError = class extends ConduitError { - constructor(message = "Authentication failed", context) { - super(message, 401, "AUTH_ERROR", context); - } -}; -var AuthenticationError = class extends AuthError { -}; -var AuthorizationError = class extends ConduitError { - constructor(message = "Access forbidden", context) { - super(message, 403, "AUTHORIZATION_ERROR", context); - } -}; -var ValidationError = class extends ConduitError { - field; - constructor(message = "Validation failed", context) { - super(message, 400, "VALIDATION_ERROR", context); - this.field = context?.field; - } -}; -var NotFoundError = class extends ConduitError { - constructor(message = "Resource not found", context) { - super(message, 404, "NOT_FOUND", context); - } -}; -var ConflictError = class extends ConduitError { - constructor(message = "Resource conflict", context) { - super(message, 409, "CONFLICT_ERROR", context); - } -}; -var InsufficientBalanceError = class extends ConduitError { - balance; - requiredAmount; - constructor(message = "Insufficient balance to complete request", context) { - super(message, 402, "INSUFFICIENT_BALANCE", context); - this.balance = context?.balance; - this.requiredAmount = context?.requiredAmount; - } -}; -var RateLimitError = class extends ConduitError { - retryAfter; - constructor(message = "Rate limit exceeded", retryAfter, context) { - super(message, 429, "RATE_LIMIT_ERROR", { ...context, retryAfter }); - this.retryAfter = retryAfter; - } -}; -var ServerError = class extends ConduitError { - constructor(message = "Internal server error", context) { - super(message, 500, "SERVER_ERROR", context); - } -}; -var NetworkError = class extends ConduitError { - constructor(message = "Network error", context) { - super(message, 0, "NETWORK_ERROR", context); - } -}; -var TimeoutError = class extends ConduitError { - constructor(message = "Request timeout", context) { - super(message, 408, "TIMEOUT_ERROR", context); - } -}; -var NotImplementedError = class extends ConduitError { - constructor(message, context) { - super(message, 501, "NOT_IMPLEMENTED", context); - } -}; -var StreamError = class extends ConduitError { - constructor(message = "Stream processing failed", context) { - super(message, 500, "STREAM_ERROR", context); - } -}; -function isConduitError(error) { - return error instanceof ConduitError; -} -function isAuthError(error) { - return error instanceof AuthError || error instanceof AuthenticationError; -} -function isAuthorizationError(error) { - return error instanceof AuthorizationError; -} -function isValidationError(error) { - return error instanceof ValidationError; -} -function isNotFoundError(error) { - return error instanceof NotFoundError; -} -function isConflictError(error) { - return error instanceof ConflictError; -} -function isInsufficientBalanceError(error) { - return error instanceof InsufficientBalanceError; -} -function isRateLimitError(error) { - return error instanceof RateLimitError; -} -function isNetworkError(error) { - return error instanceof NetworkError; -} -function isStreamError(error) { - return error instanceof StreamError; -} -function isTimeoutError(error) { - return error instanceof TimeoutError; -} -function isServerError(error) { - return isConduitError(error) && error.statusCode !== void 0 && error.statusCode >= 500; -} -function isSerializedConduitError(data) { - return typeof data === "object" && data !== null && "isConduitError" in data && data.isConduitError === true; -} -function isHttpError(error) { - return typeof error === "object" && error !== null && "response" in error && typeof error.response === "object"; -} -function isHttpNetworkError(error) { - return typeof error === "object" && error !== null && "request" in error && !("response" in error); -} -function isErrorLike(error) { - return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"; -} -function serializeError(error) { - if (isConduitError(error)) { - return error.toSerializable(); - } - if (error instanceof Error) { - return { - isError: true, - name: error.name, - message: error.message, - stack: process.env.NODE_ENV === "development" ? error.stack : void 0 - }; - } - return { - isError: true, - message: String(error) - }; -} -function deserializeError(data) { - if (isSerializedConduitError(data)) { - return ConduitError.fromSerializable(data); - } - if (typeof data === "object" && data !== null && "isError" in data) { - const errorData = data; - const error = new Error(errorData.message || "Unknown error"); - if (errorData.name) error.name = errorData.name; - if (errorData.stack) error.stack = errorData.stack; - return error; - } - return new Error("Unknown error"); -} -function getErrorMessage(error) { - if (isConduitError(error)) { - return error.message; - } - if (error instanceof Error) { - return error.message; - } - return "An unexpected error occurred"; -} -function getErrorStatusCode(error) { - if (isConduitError(error)) { - return error.statusCode; - } - return 500; -} -function handleApiError(error, endpoint, method) { - const context = { - endpoint, - method - }; - if (isHttpError(error)) { - const { status, data } = error.response; - const errorData = data; - const baseMessage = errorData?.error || errorData?.message || error.message; - const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : ""; - const enhancedMessage = `${baseMessage}${endpointInfo}`; - context.details = errorData?.details || data; - switch (status) { - case 400: - throw new ValidationError(enhancedMessage, context); - case 401: - throw new AuthError(enhancedMessage, context); - case 402: - throw new InsufficientBalanceError(enhancedMessage, context); - case 403: - throw new AuthorizationError(enhancedMessage, context); - case 404: - throw new NotFoundError(enhancedMessage, context); - case 409: - throw new ConflictError(enhancedMessage, context); - case 429: { - const retryAfterHeader = error.response.headers["retry-after"]; - const retryAfter = typeof retryAfterHeader === "string" ? parseInt(retryAfterHeader, 10) : void 0; - throw new RateLimitError(enhancedMessage, retryAfter, context); - } - case 500: - case 502: - case 503: - case 504: - throw new ServerError(enhancedMessage, context); - default: - throw new ConduitError(enhancedMessage, status, `HTTP_${status}`, context); - } - } else if (isHttpNetworkError(error)) { - const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : ""; - context.code = error.code; - if (error.code === "ECONNABORTED") { - throw new TimeoutError(`Request timeout${endpointInfo}`, context); - } - throw new NetworkError(`Network error: No response received${endpointInfo}`, context); - } else if (isErrorLike(error)) { - context.originalError = error; - throw new ConduitError(error.message, 500, "UNKNOWN_ERROR", context); - } else { - context.originalError = error; - throw new ConduitError("Unknown error", 500, "UNKNOWN_ERROR", context); - } -} -function createErrorFromResponse(response, statusCode) { - const context = { - type: response.error.type, - param: response.error.param - }; - return new ConduitError( - response.error.message, - statusCode || 500, - response.error.code || "API_ERROR", - context - ); -} - -// src/http/types.ts -var HttpMethod = /* @__PURE__ */ ((HttpMethod2) => { - HttpMethod2["GET"] = "GET"; - HttpMethod2["POST"] = "POST"; - HttpMethod2["PUT"] = "PUT"; - HttpMethod2["DELETE"] = "DELETE"; - HttpMethod2["PATCH"] = "PATCH"; - HttpMethod2["HEAD"] = "HEAD"; - HttpMethod2["OPTIONS"] = "OPTIONS"; - return HttpMethod2; -})(HttpMethod || {}); -function isHttpMethod(method) { - return Object.values(HttpMethod).includes(method); -} - -// src/http/parser.ts -var ResponseParser = class { - /** - * Parses a fetch Response based on content type and response type hint - */ - static async parse(response, responseType) { - const contentLength = response.headers.get("content-length"); - if (contentLength === "0" || response.status === 204) { - return void 0; - } - if (responseType) { - switch (responseType) { - case "json": - return await response.json(); - case "text": - return await response.text(); - case "blob": - return await response.blob(); - case "arraybuffer": - return await response.arrayBuffer(); - case "stream": - if (!response.body) { - throw new Error("Response body is not a stream"); - } - return response.body; - default: { - const _exhaustive = responseType; - throw new Error(`Unknown response type: ${String(_exhaustive)}`); - } - } - } - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - return await response.json(); - } - if (contentType.includes("text/") || contentType.includes("application/xml")) { - return await response.text(); - } - if (contentType.includes("application/octet-stream") || contentType.includes("image/") || contentType.includes("audio/") || contentType.includes("video/")) { - return await response.blob(); - } - return await response.text(); - } - /** - * Creates a clean RequestInit object without custom properties - */ - static cleanRequestInit(init) { - const { responseType, timeout, metadata, ...standardInit } = init; - return standardInit; - } -}; - -// src/http/constants.ts -var HTTP_HEADERS = { - CONTENT_TYPE: "Content-Type", - AUTHORIZATION: "Authorization", - X_API_KEY: "X-API-Key", - USER_AGENT: "User-Agent", - X_CORRELATION_ID: "X-Correlation-Id", - RETRY_AFTER: "Retry-After", - ACCEPT: "Accept", - CACHE_CONTROL: "Cache-Control" -}; -var CONTENT_TYPES = { - JSON: "application/json", - FORM_DATA: "multipart/form-data", - FORM_URLENCODED: "application/x-www-form-urlencoded", - TEXT_PLAIN: "text/plain", - TEXT_STREAM: "text/event-stream" -}; -var HTTP_STATUS = { - // 2xx Success - OK: 200, - CREATED: 201, - NO_CONTENT: 204, - // 4xx Client Errors - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - TOO_MANY_REQUESTS: 429, - RATE_LIMITED: 429, - // Alias for Core SDK compatibility - // 5xx Server Errors - INTERNAL_SERVER_ERROR: 500, - INTERNAL_ERROR: 500, - // Alias for Admin SDK compatibility - BAD_GATEWAY: 502, - SERVICE_UNAVAILABLE: 503, - GATEWAY_TIMEOUT: 504 -}; -var ERROR_CODES = { - CONNECTION_ABORTED: "ECONNABORTED", - TIMEOUT: "ETIMEDOUT", - CONNECTION_RESET: "ECONNRESET", - NETWORK_UNREACHABLE: "ENETUNREACH", - CONNECTION_REFUSED: "ECONNREFUSED", - HOST_NOT_FOUND: "ENOTFOUND" -}; -var TIMEOUTS = { - DEFAULT_REQUEST: 6e4, - // 60 seconds - SHORT_REQUEST: 1e4, - // 10 seconds - LONG_REQUEST: 3e5, - // 5 minutes - STREAMING: 0 - // No timeout for streaming -}; -var RETRY_CONFIG = { - DEFAULT_MAX_RETRIES: 3, - INITIAL_DELAY: 1e3, - // 1 second - MAX_DELAY: 3e4, - // 30 seconds - BACKOFF_FACTOR: 2 -}; - -// src/signalr/types.ts -var HubConnectionState = /* @__PURE__ */ ((HubConnectionState3) => { - HubConnectionState3["Disconnected"] = "Disconnected"; - HubConnectionState3["Connecting"] = "Connecting"; - HubConnectionState3["Connected"] = "Connected"; - HubConnectionState3["Disconnecting"] = "Disconnecting"; - HubConnectionState3["Reconnecting"] = "Reconnecting"; - return HubConnectionState3; -})(HubConnectionState || {}); -var SignalRLogLevel = /* @__PURE__ */ ((SignalRLogLevel2) => { - SignalRLogLevel2[SignalRLogLevel2["Trace"] = 0] = "Trace"; - SignalRLogLevel2[SignalRLogLevel2["Debug"] = 1] = "Debug"; - SignalRLogLevel2[SignalRLogLevel2["Information"] = 2] = "Information"; - SignalRLogLevel2[SignalRLogLevel2["Warning"] = 3] = "Warning"; - SignalRLogLevel2[SignalRLogLevel2["Error"] = 4] = "Error"; - SignalRLogLevel2[SignalRLogLevel2["Critical"] = 5] = "Critical"; - SignalRLogLevel2[SignalRLogLevel2["None"] = 6] = "None"; - return SignalRLogLevel2; -})(SignalRLogLevel || {}); -var HttpTransportType = /* @__PURE__ */ ((HttpTransportType3) => { - HttpTransportType3[HttpTransportType3["None"] = 0] = "None"; - HttpTransportType3[HttpTransportType3["WebSockets"] = 1] = "WebSockets"; - HttpTransportType3[HttpTransportType3["ServerSentEvents"] = 2] = "ServerSentEvents"; - HttpTransportType3[HttpTransportType3["LongPolling"] = 4] = "LongPolling"; - return HttpTransportType3; -})(HttpTransportType || {}); -var DefaultTransports = 1 /* WebSockets */ | 2 /* ServerSentEvents */ | 4 /* LongPolling */; -var SignalRProtocolType = /* @__PURE__ */ ((SignalRProtocolType2) => { - SignalRProtocolType2["Json"] = "json"; - SignalRProtocolType2["MessagePack"] = "messagepack"; - return SignalRProtocolType2; -})(SignalRProtocolType || {}); - -// src/signalr/BaseSignalRConnection.ts -import * as signalR from "@microsoft/signalr"; -var MessagePackHubProtocol; -async function loadMessagePackProtocol() { - if (!MessagePackHubProtocol) { - try { - const msgpack = await import("@microsoft/signalr-protocol-msgpack"); - MessagePackHubProtocol = msgpack.MessagePackHubProtocol; - return msgpack.MessagePackHubProtocol; - } catch (error) { - console.warn("MessagePack protocol not available, using JSON:", error); - return null; - } - } - return MessagePackHubProtocol; -} -var BaseSignalRConnection = class { - connection; - config; - connectionReadyPromise; - connectionReadyResolve; - connectionReadyReject; - disposed = false; - constructor(config) { - this.config = { - ...config, - baseUrl: config.baseUrl.replace(/\/$/, "") - }; - this.connectionReadyPromise = new Promise((resolve, reject) => { - this.connectionReadyResolve = resolve; - this.connectionReadyReject = reject; - }); - } - /** - * Gets whether the connection is established and ready for use. - */ - get isConnected() { - return this.connection?.state === signalR.HubConnectionState.Connected; - } - /** - * Gets the current connection state. - */ - get state() { - if (!this.connection) { - return "Disconnected" /* Disconnected */; - } - switch (this.connection.state) { - case signalR.HubConnectionState.Connected: - return "Connected" /* Connected */; - case signalR.HubConnectionState.Connecting: - return "Connecting" /* Connecting */; - case signalR.HubConnectionState.Disconnected: - return "Disconnected" /* Disconnected */; - case signalR.HubConnectionState.Disconnecting: - return "Disconnecting" /* Disconnecting */; - case signalR.HubConnectionState.Reconnecting: - return "Reconnecting" /* Reconnecting */; - default: - return "Disconnected" /* Disconnected */; - } - } - /** - * Event handlers - */ - onConnected; - onDisconnected; - onReconnecting; - onReconnected; - /** - * Establishes the SignalR connection. - */ - async getConnection() { - if (this.connection) { - return this.connection; - } - const hubUrl = `${this.config.baseUrl}${this.hubPath}`; - const connectionOptions = { - accessTokenFactory: this.config.options?.accessTokenFactory || (() => this.config.auth.authToken), - transport: this.mapTransportType(this.config.options?.transport || DefaultTransports), - headers: this.buildHeaders(), - withCredentials: false - }; - const builder = new signalR.HubConnectionBuilder().withUrl(hubUrl, connectionOptions).withAutomaticReconnect(this.config.options?.reconnectionDelay || [0, 2e3, 1e4, 3e4]); - if (this.config.options?.serverTimeout) { - builder.withServerTimeout(this.config.options.serverTimeout); - } - if (this.config.options?.keepAliveInterval) { - builder.withKeepAliveInterval(this.config.options.keepAliveInterval); - } - const logLevel = this.mapLogLevel(this.config.options?.logLevel || 2 /* Information */); - builder.configureLogging(logLevel); - const protocolType = this.config.options?.protocol || "json" /* Json */; - if (protocolType === "messagepack" /* MessagePack */) { - try { - const MessagePackProtocol = await loadMessagePackProtocol(); - if (MessagePackProtocol) { - builder.withHubProtocol(new MessagePackProtocol()); - console.warn("Using MessagePack protocol for SignalR connection"); - } - } catch (error) { - console.error("Failed to load MessagePack protocol, falling back to JSON:", error); - } - } - this.connection = builder.build(); - this.connection.onclose(async (error) => { - if (this.onDisconnected) { - await this.onDisconnected(error); - } - }); - this.connection.onreconnecting(async (error) => { - if (this.onReconnecting) { - await this.onReconnecting(error); - } - }); - this.connection.onreconnected(async (connectionId) => { - if (this.onReconnected) { - await this.onReconnected(connectionId); - } - }); - this.configureHubHandlers(this.connection); - try { - await this.connection.start(); - if (this.connectionReadyResolve) { - this.connectionReadyResolve(); - } - if (this.onConnected) { - await this.onConnected(); - } - } catch (error) { - if (this.connectionReadyReject) { - this.connectionReadyReject(error); - } - throw error; - } - return this.connection; - } - /** - * Maps transport type enum to SignalR transport. - */ - mapTransportType(transport) { - let result = signalR.HttpTransportType.None; - if (transport & 1 /* WebSockets */) { - result |= signalR.HttpTransportType.WebSockets; - } - if (transport & 2 /* ServerSentEvents */) { - result |= signalR.HttpTransportType.ServerSentEvents; - } - if (transport & 4 /* LongPolling */) { - result |= signalR.HttpTransportType.LongPolling; - } - return result; - } - /** - * Maps log level enum to SignalR log level. - */ - mapLogLevel(level) { - switch (level) { - case 0 /* Trace */: - return signalR.LogLevel.Trace; - case 1 /* Debug */: - return signalR.LogLevel.Debug; - case 2 /* Information */: - return signalR.LogLevel.Information; - case 3 /* Warning */: - return signalR.LogLevel.Warning; - case 4 /* Error */: - return signalR.LogLevel.Error; - case 5 /* Critical */: - return signalR.LogLevel.Critical; - case 6 /* None */: - return signalR.LogLevel.None; - default: - return signalR.LogLevel.Information; - } - } - /** - * Builds headers for the connection based on configuration. - */ - buildHeaders() { - const headers = { - "User-Agent": this.config.userAgent || "Conduit-Node-Client/1.0.0", - ...this.config.options?.headers - }; - if (this.config.auth.authType === "master" && this.config.auth.additionalHeaders) { - Object.assign(headers, this.config.auth.additionalHeaders); - } - return headers; - } - /** - * Waits for the connection to be ready. - */ - async waitForReady() { - return this.connectionReadyPromise; - } - /** - * Invokes a method on the hub with proper error handling. - */ - async invoke(methodName, ...args) { - if (this.disposed) { - throw new Error("Connection has been disposed"); - } - const connection = await this.getConnection(); - try { - return await connection.invoke(methodName, ...args); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`SignalR invoke error for ${methodName}: ${errorMessage}`); - } - } - /** - * Sends a message to the hub without expecting a response. - */ - async send(methodName, ...args) { - if (this.disposed) { - throw new Error("Connection has been disposed"); - } - const connection = await this.getConnection(); - try { - await connection.send(methodName, ...args); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`SignalR send error for ${methodName}: ${errorMessage}`); - } - } - /** - * Disconnects the SignalR connection. - */ - async disconnect() { - if (this.connection && this.connection.state !== signalR.HubConnectionState.Disconnected) { - await this.connection.stop(); - this.connection = void 0; - this.connectionReadyPromise = new Promise((resolve, reject) => { - this.connectionReadyResolve = resolve; - this.connectionReadyReject = reject; - }); - } - } - /** - * Disposes of the connection and cleans up resources. - */ - async dispose() { - this.disposed = true; - await this.disconnect(); - this.connectionReadyResolve = void 0; - this.connectionReadyReject = void 0; - } -}; - -// src/client/types.ts -var HttpError = class extends Error { - code; - response; - request; - config; - constructor(message, code) { - super(message); - this.name = "HttpError"; - this.code = code; - } -}; - -// src/client/retry-strategy.ts -var RetryStrategyType = /* @__PURE__ */ ((RetryStrategyType2) => { - RetryStrategyType2["FIXED_DELAY"] = "fixed_delay"; - RetryStrategyType2["EXPONENTIAL_BACKOFF"] = "exponential_backoff"; - RetryStrategyType2["CUSTOM_DELAYS"] = "custom_delays"; - return RetryStrategyType2; -})(RetryStrategyType || {}); -function calculateRetryDelay(strategy, attempt) { - switch (strategy.type) { - case "fixed_delay" /* FIXED_DELAY */: - return strategy.delayMs; - case "exponential_backoff" /* EXPONENTIAL_BACKOFF */: { - const delay = Math.min( - strategy.initialDelayMs * Math.pow(strategy.factor, attempt - 1), - strategy.maxDelayMs - ); - if (strategy.jitter) { - return delay + Math.random() * 1e3; - } - return delay; - } - case "custom_delays" /* CUSTOM_DELAYS */: { - const index = Math.min(attempt - 1, strategy.delays.length - 1); - return strategy.delays[index]; - } - } -} -function getMaxRetries(strategy) { - switch (strategy.type) { - case "fixed_delay" /* FIXED_DELAY */: - case "exponential_backoff" /* EXPONENTIAL_BACKOFF */: - return strategy.maxRetries; - case "custom_delays" /* CUSTOM_DELAYS */: - return strategy.delays.length; - } -} -function shouldRetryWithStrategy(strategy, error) { - if (strategy.retryCondition) { - return strategy.retryCondition(error); - } - return false; -} -var DEFAULT_RETRY_STRATEGIES = { - /** Gateway SDK default: exponential backoff with jitter */ - gateway: { - type: "exponential_backoff" /* EXPONENTIAL_BACKOFF */, - maxRetries: 3, - initialDelayMs: 1e3, - maxDelayMs: 3e4, - factor: 2, - jitter: true - }, - /** Admin SDK default: fixed delay */ - admin: { - type: "fixed_delay" /* FIXED_DELAY */, - maxRetries: 3, - delayMs: 1e3 - } -}; - -// src/client/BaseApiClient.ts -var BaseApiClient = class { - /** Base URL for all requests (without trailing slash) */ - baseUrl; - /** Default timeout in milliseconds */ - timeout; - /** Default headers included with all requests */ - defaultHeaders; - /** Retry strategy configuration */ - retryStrategy; - /** Enable debug logging */ - debug; - // Lifecycle callbacks - onError; - onRequest; - onResponse; - // Optional providers (Admin SDK uses these, Gateway SDK may not) - logger; - cache; - constructor(config) { - this.baseUrl = config.baseUrl.replace(/\/$/, ""); - this.timeout = config.timeout ?? 6e4; - this.defaultHeaders = config.defaultHeaders ?? {}; - this.retryStrategy = config.retryStrategy ?? this.getDefaultRetryStrategy(); - this.debug = config.debug ?? false; - this.onError = config.onError; - this.onRequest = config.onRequest; - this.onResponse = config.onResponse; - this.logger = config.logger; - this.cache = config.cache; - } - // ============================================================================ - // Template Methods - Can be overridden by SDK-specific clients - // ============================================================================ - /** - * Transform error response into appropriate error type - * Subclasses can override for SDK-specific error handling - * - * @param response - The failed Response object - * @returns An Error to throw - */ - async handleErrorResponse(response) { - let errorData; - try { - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - errorData = await response.json(); - } - } catch { - errorData = {}; - } - return new ConduitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status, - `HTTP_${response.status}`, - { data: errorData } - ); - } - /** - * Determine if an error should be retried - * Subclasses can override for SDK-specific retry logic - * - * @param error - The error that occurred - * @param attempt - Current attempt number (1-based) - * @returns Whether to retry the request - */ - shouldRetry(error, attempt) { - const maxRetries = getMaxRetries(this.retryStrategy); - if (attempt > maxRetries) return false; - if (this.retryStrategy.retryCondition) { - return this.retryStrategy.retryCondition(error); - } - if (error instanceof ConduitError) { - return error.statusCode === 429 || error.statusCode >= 500; - } - if (error instanceof Error) { - return error.name === "AbortError" || error.message.includes("network") || error.message.includes("fetch"); - } - return false; - } - /** - * Calculate delay for a retry attempt - * Subclasses can override for special cases (e.g., retry-after headers) - * - * @param error - The error that triggered the retry - * @param attempt - Current attempt number (1-based) - * @returns Delay in milliseconds before next retry - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getRetryDelay(_error, attempt) { - return calculateRetryDelay(this.retryStrategy, attempt); - } - // ============================================================================ - // HTTP Methods - // ============================================================================ - /** - * Main request method with retry logic - */ - async request(url, options = {}) { - const fullUrl = this.buildUrl(url); - const controller = new AbortController(); - const timeoutMs = options.timeout ?? this.timeout; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - try { - const requestInfo = { - method: options.method ?? "GET" /* GET */, - url: fullUrl, - headers: this.buildHeaders(options.headers), - data: options.body - }; - if (this.onRequest) { - await this.onRequest(requestInfo); - } - this.log("debug", `API Request: ${requestInfo.method} ${requestInfo.url}`); - const response = await this.executeWithRetry( - fullUrl, - { - method: requestInfo.method, - headers: requestInfo.headers, - body: options.body ? JSON.stringify(options.body) : void 0, - signal: options.signal ?? controller.signal, - responseType: options.responseType, - timeout: timeoutMs - } - ); - return response; - } finally { - clearTimeout(timeoutId); - } - } - /** - * Type-safe GET request - */ - async get(url, options) { - return this.request(url, { ...options, method: "GET" /* GET */ }); - } - /** - * Type-safe POST request - */ - async post(url, data, options) { - return this.request(url, { - ...options, - method: "POST" /* POST */, - body: data - }); - } - /** - * Type-safe PUT request - */ - async put(url, data, options) { - return this.request(url, { - ...options, - method: "PUT" /* PUT */, - body: data - }); - } - /** - * Type-safe PATCH request - */ - async patch(url, data, options) { - return this.request(url, { - ...options, - method: "PATCH" /* PATCH */, - body: data - }); - } - /** - * Type-safe DELETE request - */ - async delete(url, options) { - return this.request(url, { ...options, method: "DELETE" /* DELETE */ }); - } - // ============================================================================ - // Internal Methods - // ============================================================================ - /** - * Execute request with retry logic - */ - async executeWithRetry(url, init, attempt = 1) { - try { - const response = await fetch(url, ResponseParser.cleanRequestInit(init)); - this.log("debug", `API Response: ${response.status} ${response.statusText}`); - const headers = {}; - response.headers.forEach((value, key) => { - headers[key] = value; - }); - if (this.onResponse) { - const responseInfo = { - status: response.status, - statusText: response.statusText, - headers, - data: void 0, - config: { - url, - method: init.method ?? "GET" /* GET */, - headers: init.headers ?? {} - } - }; - await this.onResponse(responseInfo); - } - if (!response.ok) { - const error = await this.handleErrorResponse(response); - throw error; - } - const contentLength = response.headers.get("content-length"); - if (contentLength === "0" || response.status === 204) { - return void 0; - } - return await ResponseParser.parse(response, init.responseType); - } catch (error) { - if (this.shouldRetry(error, attempt)) { - const delay = this.getRetryDelay(error, attempt); - this.log("debug", `Retrying request (attempt ${attempt + 1}) after ${delay}ms`); - await this.sleep(delay); - return this.executeWithRetry(url, init, attempt + 1); - } - if (this.onError && error instanceof Error) { - this.onError(error); - } - throw error; - } - } - /** - * Build full URL from path - */ - buildUrl(path) { - if (path.startsWith("http://") || path.startsWith("https://")) { - return path; - } - const cleanPath = path.startsWith("/") ? path : `/${path}`; - return `${this.baseUrl}${cleanPath}`; - } - /** - * Build headers including auth, defaults, and additional headers - */ - buildHeaders(additionalHeaders) { - return { - [HTTP_HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON, - ...this.getAuthHeaders(), - ...this.defaultHeaders, - ...additionalHeaders - }; - } - /** - * Log a message using the configured logger or console in debug mode - */ - log(level, message, ...args) { - if (this.logger?.[level]) { - this.logger[level](message, ...args); - } else if (this.debug && level === "debug") { - console.warn(`[SDK] ${message}`, ...args); - } - } - /** - * Sleep for a specified duration - */ - sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - // ============================================================================ - // Caching Utilities (Optional - only active if cache provider is configured) - // ============================================================================ - /** - * Get a value from cache - * Returns null if cache is not configured or key is not found - */ - async getFromCache(key) { - if (!this.cache) return null; - try { - const cached = await this.cache.get(key); - if (cached) { - this.log("debug", `Cache hit for key: ${key}`); - return cached; - } - } catch (error) { - this.log("error", "Cache get error:", error); - } - return null; - } - /** - * Set a value in cache - * No-op if cache is not configured - */ - async setCache(key, value, ttl) { - if (!this.cache) return; - try { - await this.cache.set(key, value, ttl); - this.log("debug", `Cache set for key: ${key}`); - } catch (error) { - this.log("error", "Cache set error:", error); - } - } - /** - * Execute a function with caching - * Returns cached value if available, otherwise executes function and caches result - */ - async withCache(cacheKey, fn, ttl) { - const cached = await this.getFromCache(cacheKey); - if (cached !== null) { - return cached; - } - const result = await fn(); - await this.setCache(cacheKey, result, ttl); - return result; - } - /** - * Generate a cache key from resource and identifiers - */ - getCacheKey(resource, ...identifiers) { - const parts = identifiers.filter((id) => id !== void 0).map((id) => typeof id === "object" ? JSON.stringify(id) : String(id)); - return `${resource}:${parts.join(":")}`; - } -}; - -// src/circuit-breaker/types.ts -var CircuitState = /* @__PURE__ */ ((CircuitState2) => { - CircuitState2["CLOSED"] = "closed"; - CircuitState2["OPEN"] = "open"; - CircuitState2["HALF_OPEN"] = "half_open"; - return CircuitState2; -})(CircuitState || {}); - -// src/circuit-breaker/errors.ts -var CircuitBreakerOpenError = class extends ConduitError { - /** Current circuit breaker state */ - circuitState; - /** Time until circuit transitions to HALF_OPEN (milliseconds) */ - timeUntilHalfOpen; - /** Circuit breaker statistics at time of rejection */ - stats; - constructor(message, stats, timeUntilHalfOpen) { - super(message, 503, "CIRCUIT_BREAKER_OPEN", { - circuitState: stats.state, - timeUntilHalfOpen, - consecutiveFailures: stats.consecutiveFailures, - totalFailures: stats.totalFailures - }); - this.circuitState = stats.state; - this.timeUntilHalfOpen = timeUntilHalfOpen; - this.stats = stats; - } -}; -function isCircuitBreakerOpenError(error) { - return error instanceof CircuitBreakerOpenError; -} - -// src/circuit-breaker/CircuitBreaker.ts -var DEFAULT_CONFIG = { - failureThreshold: 3, - failureWindowMs: 6e4, - // 60 seconds - resetTimeoutMs: 3e4, - // 30 seconds - successThreshold: 1, - enableLogging: false -}; -var CircuitBreaker = class { - config; - callbacks; - // State tracking - state = "closed" /* CLOSED */; - failures = []; - halfOpenSuccesses = 0; - // Statistics - totalFailures = 0; - totalSuccesses = 0; - rejectedRequests = 0; - circuitOpenedAt = null; - lastFailureAt = null; - lastSuccessAt = null; - constructor(config = {}, callbacks = {}) { - this.config = { - ...DEFAULT_CONFIG, - ...config - }; - this.callbacks = callbacks; - } - /** - * Get current state of the circuit - * Automatically transitions OPEN -> HALF_OPEN after timeout - */ - getState() { - if (this.state === "open" /* OPEN */ && this.circuitOpenedAt !== null) { - const elapsed = Date.now() - this.circuitOpenedAt; - if (elapsed >= this.config.resetTimeoutMs) { - this.transitionTo("half_open" /* HALF_OPEN */); - } - } - return this.state; - } - /** - * Get circuit breaker statistics - */ - getStats() { - const currentState = this.getState(); - return { - state: currentState, - consecutiveFailures: this.getConsecutiveFailuresInWindow(), - totalFailures: this.totalFailures, - totalSuccesses: this.totalSuccesses, - circuitOpenedAt: this.circuitOpenedAt, - timeUntilHalfOpen: this.calculateTimeUntilHalfOpen(), - lastFailureAt: this.lastFailureAt, - lastSuccessAt: this.lastSuccessAt, - rejectedRequests: this.rejectedRequests - }; - } - /** - * Check if a request can proceed - * Returns true if circuit is CLOSED or HALF_OPEN - */ - canExecute() { - const state = this.getState(); - return state !== "open" /* OPEN */; - } - /** - * Check if request should proceed, throwing if circuit is open - * @throws CircuitBreakerOpenError if circuit is OPEN - */ - checkOpen() { - const state = this.getState(); - if (state === "open" /* OPEN */) { - this.rejectedRequests++; - const stats = this.getStats(); - this.callbacks.onRejected?.(stats); - throw new CircuitBreakerOpenError( - `Circuit breaker is open. Try again in ${Math.ceil((stats.timeUntilHalfOpen ?? 0) / 1e3)} seconds.`, - stats, - stats.timeUntilHalfOpen - ); - } - } - /** - * Record a successful request - */ - recordSuccess() { - this.totalSuccesses++; - this.lastSuccessAt = Date.now(); - const currentState = this.getState(); - if (currentState === "half_open" /* HALF_OPEN */) { - this.halfOpenSuccesses++; - this.log("debug", `Half-open success ${this.halfOpenSuccesses}/${this.config.successThreshold}`); - if (this.halfOpenSuccesses >= this.config.successThreshold) { - this.transitionTo("closed" /* CLOSED */); - } - } else if (currentState === "closed" /* CLOSED */) { - this.failures = []; - } - } - /** - * Record a failed request - */ - recordFailure(error) { - if (this.config.shouldCountAsFailure && !this.config.shouldCountAsFailure(error)) { - this.log("debug", "Error not counted as failure by custom filter"); - return; - } - const now = Date.now(); - this.totalFailures++; - this.lastFailureAt = now; - const currentState = this.getState(); - if (currentState === "half_open" /* HALF_OPEN */) { - this.log("warn", "Failure in half-open state, reopening circuit"); - this.transitionTo("open" /* OPEN */, error); - return; - } - if (currentState === "closed" /* CLOSED */) { - this.failures.push({ timestamp: now, error }); - this.pruneOldFailures(); - const consecutiveFailures = this.getConsecutiveFailuresInWindow(); - this.log("debug", `Consecutive failures: ${consecutiveFailures}/${this.config.failureThreshold}`); - if (consecutiveFailures >= this.config.failureThreshold) { - this.transitionTo("open" /* OPEN */, error); - } - } - } - /** - * Manually reset the circuit to CLOSED state - * Use with caution - typically for testing or admin override - */ - reset() { - this.log("info", "Circuit manually reset"); - this.transitionTo("closed" /* CLOSED */); - this.failures = []; - this.totalFailures = 0; - this.totalSuccesses = 0; - this.rejectedRequests = 0; - } - // Private methods - transitionTo(newState, triggerError) { - const oldState = this.state; - if (oldState === newState) return; - this.state = newState; - const stats = this.getStats(); - this.log("info", `Circuit state change: ${oldState} -> ${newState}`); - switch (newState) { - case "open" /* OPEN */: - this.circuitOpenedAt = Date.now(); - this.halfOpenSuccesses = 0; - this.callbacks.onOpen?.(stats, triggerError); - break; - case "half_open" /* HALF_OPEN */: - this.halfOpenSuccesses = 0; - this.callbacks.onHalfOpen?.(stats); - break; - case "closed" /* CLOSED */: - this.circuitOpenedAt = null; - this.failures = []; - this.halfOpenSuccesses = 0; - this.callbacks.onClose?.(stats); - break; - } - this.callbacks.onStateChange?.(oldState, newState, stats); - } - pruneOldFailures() { - const cutoff = Date.now() - this.config.failureWindowMs; - this.failures = this.failures.filter((f) => f.timestamp >= cutoff); - } - getConsecutiveFailuresInWindow() { - this.pruneOldFailures(); - return this.failures.length; - } - calculateTimeUntilHalfOpen() { - if (this.state !== "open" /* OPEN */ || this.circuitOpenedAt === null) { - return null; - } - const elapsed = Date.now() - this.circuitOpenedAt; - const remaining = this.config.resetTimeoutMs - elapsed; - return remaining > 0 ? remaining : 0; - } - log(_level, message) { - if (this.config.enableLogging) { - console.warn(`[CircuitBreaker] ${message}`); - } - } -}; -export { - AuthError, - AuthenticationError, - AuthorizationError, - BaseApiClient, - BaseSignalRConnection, - CONTENT_TYPES, - CircuitBreaker, - CircuitBreakerOpenError, - CircuitState, - ConduitError, - ConflictError, - DEFAULT_RETRY_STRATEGIES, - DefaultTransports, - ERROR_CODES, - HTTP_HEADERS, - HTTP_STATUS, - HttpError, - HttpMethod, - HttpTransportType, - HubConnectionState, - InsufficientBalanceError, - ModelCapability, - NetworkError, - NotFoundError, - NotImplementedError, - RETRY_CONFIG, - RateLimitError, - ResponseParser, - RetryStrategyType, - ServerError, - SignalRLogLevel, - SignalRProtocolType, - StreamError, - TIMEOUTS, - TimeoutError, - ValidationError, - calculateRetryDelay, - createErrorFromResponse, - deserializeError, - getCapabilityCategory, - getCapabilityDisplayName, - getErrorMessage, - getErrorStatusCode, - getMaxRetries, - handleApiError, - isAuthError, - isAuthorizationError, - isCircuitBreakerOpenError, - isConduitError, - isConflictError, - isErrorLike, - isHttpError, - isHttpMethod, - isHttpNetworkError, - isInsufficientBalanceError, - isNetworkError, - isNotFoundError, - isRateLimitError, - isSerializedConduitError, - isServerError, - isStreamError, - isTimeoutError, - isValidationError, - serializeError, - shouldRetryWithStrategy -}; -//# sourceMappingURL=index.mjs.map \ No newline at end of file diff --git a/SDKs/Node/Common/dist/index.mjs.map b/SDKs/Node/Common/dist/index.mjs.map deleted file mode 100644 index 6313923f5..000000000 --- a/SDKs/Node/Common/dist/index.mjs.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["../src/types/capabilities.ts","../src/errors/index.ts","../src/http/types.ts","../src/http/parser.ts","../src/http/constants.ts","../src/signalr/types.ts","../src/signalr/BaseSignalRConnection.ts","../src/client/types.ts","../src/client/retry-strategy.ts","../src/client/BaseApiClient.ts","../src/circuit-breaker/types.ts","../src/circuit-breaker/errors.ts","../src/circuit-breaker/CircuitBreaker.ts"],"sourcesContent":["/**\n * Model capability definitions shared across Conduit SDK clients\n */\n\n/**\n * Core model capabilities supported by Conduit\n */\nexport enum ModelCapability {\n CHAT = 'chat',\n VISION = 'vision',\n IMAGE_GENERATION = 'image-generation',\n IMAGE_EDIT = 'image-edit',\n IMAGE_VARIATION = 'image-variation',\n AUDIO_TRANSCRIPTION = 'audio-transcription',\n TEXT_TO_SPEECH = 'text-to-speech',\n REALTIME_AUDIO = 'realtime-audio',\n EMBEDDINGS = 'embeddings',\n VIDEO_GENERATION = 'video-generation',\n}\n\n/**\n * Model capability metadata\n */\nexport interface ModelCapabilityInfo {\n id: ModelCapability;\n displayName: string;\n description?: string;\n category: 'text' | 'vision' | 'audio' | 'video';\n}\n\n/**\n * Model capabilities definition for a specific model\n */\nexport interface ModelCapabilities {\n modelId: string;\n capabilities: ModelCapability[];\n constraints?: ModelConstraints;\n}\n\n/**\n * Model-specific constraints\n */\nexport interface ModelConstraints {\n maxTokens?: number;\n maxImages?: number;\n supportedImageSizes?: string[];\n supportedImageFormats?: string[];\n supportedAudioFormats?: string[];\n supportedVideoSizes?: string[];\n supportedLanguages?: string[];\n supportedVoices?: string[];\n maxDuration?: number;\n}\n\n/**\n * Get user-friendly display name for a capability\n */\nexport function getCapabilityDisplayName(capability: ModelCapability): string {\n switch (capability) {\n case ModelCapability.CHAT:\n return 'Chat Completion';\n case ModelCapability.VISION:\n return 'Vision (Image Understanding)';\n case ModelCapability.IMAGE_GENERATION:\n return 'Image Generation';\n case ModelCapability.IMAGE_EDIT:\n return 'Image Editing';\n case ModelCapability.IMAGE_VARIATION:\n return 'Image Variation';\n case ModelCapability.AUDIO_TRANSCRIPTION:\n return 'Audio Transcription';\n case ModelCapability.TEXT_TO_SPEECH:\n return 'Text-to-Speech';\n case ModelCapability.REALTIME_AUDIO:\n return 'Realtime Audio';\n case ModelCapability.EMBEDDINGS:\n return 'Embeddings';\n case ModelCapability.VIDEO_GENERATION:\n return 'Video Generation';\n default:\n return capability;\n }\n}\n\n/**\n * Get capability category\n */\nexport function getCapabilityCategory(capability: ModelCapability): 'text' | 'vision' | 'audio' | 'video' {\n switch (capability) {\n case ModelCapability.CHAT:\n case ModelCapability.EMBEDDINGS:\n return 'text';\n case ModelCapability.VISION:\n case ModelCapability.IMAGE_GENERATION:\n case ModelCapability.IMAGE_EDIT:\n case ModelCapability.IMAGE_VARIATION:\n return 'vision';\n case ModelCapability.AUDIO_TRANSCRIPTION:\n case ModelCapability.TEXT_TO_SPEECH:\n case ModelCapability.REALTIME_AUDIO:\n return 'audio';\n case ModelCapability.VIDEO_GENERATION:\n return 'video';\n default:\n return 'text';\n }\n}","/**\n * Common error types for Conduit SDK clients\n * \n * This module provides a unified error hierarchy for both Admin and Core SDKs,\n * consolidating previously duplicated error classes.\n */\n\nexport class ConduitError extends Error {\n public statusCode: number;\n public code: string;\n public context?: Record;\n \n // Admin SDK specific fields\n public details?: unknown;\n public endpoint?: string;\n public method?: string;\n \n // Core SDK specific fields\n public type?: string;\n public param?: string;\n\n constructor(\n message: string,\n statusCode: number = 500,\n code: string = 'INTERNAL_ERROR',\n context?: Record\n ) {\n super(message);\n this.name = this.constructor.name;\n this.statusCode = statusCode;\n this.code = code;\n this.context = context;\n \n // Preserve additional context from the constructor pattern\n if (context) {\n // Admin SDK fields\n this.details = context.details;\n this.endpoint = context.endpoint as string | undefined;\n this.method = context.method as string | undefined;\n \n // Core SDK fields\n this.type = context.type as string | undefined;\n this.param = context.param as string | undefined;\n }\n \n // Ensure proper prototype chain for instanceof checks\n Object.setPrototypeOf(this, new.target.prototype);\n \n // Capture stack trace for better debugging\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, this.constructor);\n }\n }\n\n toJSON() {\n return {\n name: this.name,\n message: this.message,\n statusCode: this.statusCode,\n code: this.code,\n context: this.context,\n details: this.details,\n endpoint: this.endpoint,\n method: this.method,\n type: this.type,\n param: this.param,\n timestamp: new Date().toISOString(),\n };\n }\n \n // Helper method for Next.js serialization\n toSerializable() {\n return {\n isConduitError: true,\n ...this.toJSON(),\n };\n }\n \n // Static method to reconstruct from serialized error\n static fromSerializable(data: unknown): ConduitError {\n if (!data || typeof data !== 'object' || !('isConduitError' in data) || !(data as { isConduitError: unknown }).isConduitError) {\n throw new Error('Invalid serialized ConduitError');\n }\n \n const errorData = data as unknown as {\n message: string;\n statusCode: number;\n code: string;\n context?: Record;\n details?: unknown;\n endpoint?: string;\n method?: string;\n type?: string;\n param?: string;\n };\n \n const error = new ConduitError(\n errorData.message,\n errorData.statusCode,\n errorData.code,\n errorData.context\n );\n \n // Restore additional properties\n if (errorData.details !== undefined) error.details = errorData.details;\n if (errorData.endpoint !== undefined) error.endpoint = errorData.endpoint;\n if (errorData.method !== undefined) error.method = errorData.method;\n if (errorData.type !== undefined) error.type = errorData.type;\n if (errorData.param !== undefined) error.param = errorData.param;\n \n return error;\n }\n}\n\nexport class AuthError extends ConduitError {\n constructor(message = 'Authentication failed', context?: Record) {\n super(message, 401, 'AUTH_ERROR', context);\n }\n}\n\n// Alias for backward compatibility\nexport class AuthenticationError extends AuthError {}\n\nexport class AuthorizationError extends ConduitError {\n constructor(message = 'Access forbidden', context?: Record) {\n super(message, 403, 'AUTHORIZATION_ERROR', context);\n }\n}\n\nexport class ValidationError extends ConduitError {\n public field?: string;\n \n constructor(message = 'Validation failed', context?: Record) {\n super(message, 400, 'VALIDATION_ERROR', context);\n this.field = context?.field as string | undefined;\n }\n}\n\nexport class NotFoundError extends ConduitError {\n constructor(message = 'Resource not found', context?: Record) {\n super(message, 404, 'NOT_FOUND', context);\n }\n}\n\nexport class ConflictError extends ConduitError {\n constructor(message = 'Resource conflict', context?: Record) {\n super(message, 409, 'CONFLICT_ERROR', context);\n }\n}\n\nexport class InsufficientBalanceError extends ConduitError {\n public balance?: number;\n public requiredAmount?: number;\n\n constructor(message = 'Insufficient balance to complete request', context?: Record) {\n super(message, 402, 'INSUFFICIENT_BALANCE', context);\n this.balance = context?.balance as number | undefined;\n this.requiredAmount = context?.requiredAmount as number | undefined;\n }\n}\n\nexport class RateLimitError extends ConduitError {\n public retryAfter?: number;\n\n constructor(message = 'Rate limit exceeded', retryAfter?: number, context?: Record) {\n super(message, 429, 'RATE_LIMIT_ERROR', { ...context, retryAfter });\n this.retryAfter = retryAfter;\n }\n}\n\nexport class ServerError extends ConduitError {\n constructor(message = 'Internal server error', context?: Record) {\n super(message, 500, 'SERVER_ERROR', context);\n }\n}\n\nexport class NetworkError extends ConduitError {\n constructor(message = 'Network error', context?: Record) {\n super(message, 0, 'NETWORK_ERROR', context);\n }\n}\n\nexport class TimeoutError extends ConduitError {\n constructor(message = 'Request timeout', context?: Record) {\n super(message, 408, 'TIMEOUT_ERROR', context);\n }\n}\n\nexport class NotImplementedError extends ConduitError {\n constructor(message: string, context?: Record) {\n super(message, 501, 'NOT_IMPLEMENTED', context);\n }\n}\n\nexport class StreamError extends ConduitError {\n constructor(message = 'Stream processing failed', context?: Record) {\n super(message, 500, 'STREAM_ERROR', context);\n }\n}\n\n// Type guards\nexport function isConduitError(error: unknown): error is ConduitError {\n return error instanceof ConduitError;\n}\n\nexport function isAuthError(error: unknown): error is AuthError {\n return error instanceof AuthError || error instanceof AuthenticationError;\n}\n\nexport function isAuthorizationError(error: unknown): error is AuthorizationError {\n return error instanceof AuthorizationError;\n}\n\nexport function isValidationError(error: unknown): error is ValidationError {\n return error instanceof ValidationError;\n}\n\nexport function isNotFoundError(error: unknown): error is NotFoundError {\n return error instanceof NotFoundError;\n}\n\nexport function isConflictError(error: unknown): error is ConflictError {\n return error instanceof ConflictError;\n}\n\nexport function isInsufficientBalanceError(error: unknown): error is InsufficientBalanceError {\n return error instanceof InsufficientBalanceError;\n}\n\nexport function isRateLimitError(error: unknown): error is RateLimitError {\n return error instanceof RateLimitError;\n}\n\nexport function isNetworkError(error: unknown): error is NetworkError {\n return error instanceof NetworkError;\n}\n\nexport function isStreamError(error: unknown): error is StreamError {\n return error instanceof StreamError;\n}\n\nexport function isTimeoutError(error: unknown): error is TimeoutError {\n return error instanceof TimeoutError;\n}\n\nexport function isServerError(error: unknown): error is ConduitError {\n return isConduitError(error) &&\n error.statusCode !== undefined &&\n error.statusCode >= 500;\n}\n\n// Helper to check if an error is serialized ConduitError\nexport function isSerializedConduitError(data: unknown): data is ReturnType {\n return (\n typeof data === 'object' &&\n data !== null &&\n 'isConduitError' in data &&\n (data as { isConduitError: unknown }).isConduitError === true\n );\n}\n\n// Type guard for HTTP errors\nexport function isHttpError(error: unknown): error is {\n response: { status: number; data: unknown; headers: Record };\n message: string;\n request?: unknown;\n code?: string;\n} {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'response' in error &&\n typeof (error as { response: unknown }).response === 'object'\n );\n}\n\n// Type guard for network errors\nexport function isHttpNetworkError(error: unknown): error is {\n request: unknown;\n message: string;\n code?: string;\n} {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'request' in error &&\n !('response' in error)\n );\n}\n\n// Type guard for generic errors\nexport function isErrorLike(error: unknown): error is {\n message: string;\n} {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'message' in error &&\n typeof (error as { message: unknown }).message === 'string'\n );\n}\n\n// Next.js-specific utilities for error serialization across server/client boundaries\nexport function serializeError(error: unknown): Record {\n if (isConduitError(error)) {\n return error.toSerializable();\n }\n \n if (error instanceof Error) {\n return {\n isError: true,\n name: error.name,\n message: error.message,\n stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,\n };\n }\n \n return {\n isError: true,\n message: String(error),\n };\n}\n\nexport function deserializeError(data: unknown): Error {\n if (isSerializedConduitError(data)) {\n return ConduitError.fromSerializable(data);\n }\n \n if (typeof data === 'object' && data !== null && 'isError' in data) {\n const errorData = data as {\n message?: string;\n name?: string;\n stack?: string;\n isError: boolean;\n };\n const error = new Error(errorData.message || 'Unknown error');\n if (errorData.name) error.name = errorData.name;\n if (errorData.stack) error.stack = errorData.stack;\n return error;\n }\n \n return new Error('Unknown error');\n}\n\n// Helper for Next.js error boundaries\nexport function getErrorMessage(error: unknown): string {\n if (isConduitError(error)) {\n return error.message;\n }\n \n if (error instanceof Error) {\n return error.message;\n }\n \n return 'An unexpected error occurred';\n}\n\n// Helper for Next.js error pages\nexport function getErrorStatusCode(error: unknown): number {\n if (isConduitError(error)) {\n return error.statusCode;\n }\n \n return 500;\n}\n\n/**\n * Handle API errors and convert them to appropriate ConduitError types\n * This function is primarily used by the Admin SDK\n */\nexport function handleApiError(error: unknown, endpoint?: string, method?: string): never {\n const context: Record = {\n endpoint,\n method,\n };\n\n if (isHttpError(error)) {\n const { status, data } = error.response;\n const errorData = data as { error?: string; message?: string; details?: unknown } | null;\n const baseMessage = errorData?.error || errorData?.message || error.message;\n \n // Enhanced error messages with endpoint information\n const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : '';\n const enhancedMessage = `${baseMessage}${endpointInfo}`;\n \n // Add details to context\n context.details = errorData?.details || data;\n\n switch (status) {\n case 400:\n throw new ValidationError(enhancedMessage, context);\n case 401:\n throw new AuthError(enhancedMessage, context);\n case 402:\n throw new InsufficientBalanceError(enhancedMessage, context);\n case 403:\n throw new AuthorizationError(enhancedMessage, context);\n case 404:\n throw new NotFoundError(enhancedMessage, context);\n case 409:\n throw new ConflictError(enhancedMessage, context);\n case 429: {\n const retryAfterHeader = error.response.headers['retry-after'];\n const retryAfter = typeof retryAfterHeader === 'string' ? parseInt(retryAfterHeader, 10) : undefined;\n throw new RateLimitError(enhancedMessage, retryAfter, context);\n }\n case 500:\n case 502:\n case 503:\n case 504:\n throw new ServerError(enhancedMessage, context);\n default:\n throw new ConduitError(enhancedMessage, status, `HTTP_${status}`, context);\n }\n } else if (isHttpNetworkError(error)) {\n const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : '';\n context.code = error.code;\n \n if (error.code === 'ECONNABORTED') {\n throw new TimeoutError(`Request timeout${endpointInfo}`, context);\n }\n throw new NetworkError(`Network error: No response received${endpointInfo}`, context);\n } else if (isErrorLike(error)) {\n context.originalError = error;\n throw new ConduitError(error.message, 500, 'UNKNOWN_ERROR', context);\n } else {\n context.originalError = error;\n throw new ConduitError('Unknown error', 500, 'UNKNOWN_ERROR', context);\n }\n}\n\n/**\n * Create an error from an ErrorResponse format\n * This function is primarily used by the Core SDK for legacy compatibility\n */\nexport interface ErrorResponseFormat {\n error: {\n message: string;\n type?: string;\n code?: string;\n param?: string;\n };\n}\n\nexport function createErrorFromResponse(response: ErrorResponseFormat, statusCode?: number): ConduitError {\n const context: Record = {\n type: response.error.type,\n param: response.error.param,\n };\n \n return new ConduitError(\n response.error.message,\n statusCode || 500,\n response.error.code || 'API_ERROR',\n context\n );\n}","/**\n * HTTP methods enum for type-safe API requests\n */\nexport enum HttpMethod {\n GET = 'GET',\n POST = 'POST',\n PUT = 'PUT',\n DELETE = 'DELETE',\n PATCH = 'PATCH',\n HEAD = 'HEAD',\n OPTIONS = 'OPTIONS'\n}\n\n/**\n * Type guard to check if a string is a valid HTTP method\n */\nexport function isHttpMethod(method: string): method is HttpMethod {\n return Object.values(HttpMethod).includes(method as HttpMethod);\n}\n\n/**\n * Request options with proper typing\n */\nexport interface RequestOptions {\n headers?: Record;\n signal?: AbortSignal;\n timeout?: number;\n body?: TRequest;\n params?: Record;\n responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';\n}\n\n/**\n * Type-safe response interface\n */\nexport interface ApiResponse {\n data: T;\n status: number;\n statusText: string;\n headers: Record;\n}\n\n/**\n * Extended fetch options that include response type hints\n * This provides a cleaner way to handle different response types\n */\nexport interface ExtendedRequestInit extends RequestInit {\n /**\n * Hint for how to parse the response body\n * This is not a standard fetch option but helps our client handle responses correctly\n */\n responseType?: 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream';\n \n /**\n * Custom timeout in milliseconds\n */\n timeout?: number;\n \n /**\n * Request metadata for logging/debugging\n */\n metadata?: {\n /** Operation name for debugging */\n operation?: string;\n /** Start time for performance tracking */\n startTime?: number;\n /** Request ID for tracing */\n requestId?: string;\n };\n}","import { ExtendedRequestInit } from './types';\n\n/**\n * Response parser that handles different response types based on content-type and hints\n */\nexport class ResponseParser {\n /**\n * Parses a fetch Response based on content type and response type hint\n */\n static async parse(\n response: Response,\n responseType?: ExtendedRequestInit['responseType']\n ): Promise {\n // Handle empty responses\n const contentLength = response.headers.get('content-length');\n if (contentLength === '0' || response.status === 204) {\n return undefined as T;\n }\n \n // Use explicit responseType if provided\n if (responseType) {\n switch (responseType) {\n case 'json':\n return await response.json() as T;\n case 'text':\n return await response.text() as T;\n case 'blob':\n return await response.blob() as T;\n case 'arraybuffer':\n return await response.arrayBuffer() as T;\n case 'stream':\n if (!response.body) {\n throw new Error('Response body is not a stream');\n }\n return response.body as T;\n default: {\n // TypeScript exhaustiveness check\n const _exhaustive: never = responseType;\n throw new Error(`Unknown response type: ${String(_exhaustive)}`);\n }\n }\n }\n \n // Auto-detect based on content-type\n const contentType = response.headers.get('content-type') || '';\n \n if (contentType.includes('application/json')) {\n return await response.json() as T;\n }\n \n if (contentType.includes('text/') || contentType.includes('application/xml')) {\n return await response.text() as T;\n }\n \n if (contentType.includes('application/octet-stream') || \n contentType.includes('image/') ||\n contentType.includes('audio/') ||\n contentType.includes('video/')) {\n return await response.blob() as T;\n }\n \n // Default to text for unknown content types\n return await response.text() as T;\n }\n \n /**\n * Creates a clean RequestInit object without custom properties\n */\n static cleanRequestInit(init: ExtendedRequestInit): RequestInit {\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const { responseType, timeout, metadata, ...standardInit } = init;\n return standardInit;\n }\n}","/**\n * Common HTTP constants shared across all SDKs\n */\n\n/**\n * HTTP headers used across SDKs\n */\nexport const HTTP_HEADERS = {\n CONTENT_TYPE: 'Content-Type',\n AUTHORIZATION: 'Authorization',\n X_API_KEY: 'X-API-Key',\n USER_AGENT: 'User-Agent',\n X_CORRELATION_ID: 'X-Correlation-Id',\n RETRY_AFTER: 'Retry-After',\n ACCEPT: 'Accept',\n CACHE_CONTROL: 'Cache-Control'\n} as const;\n\nexport type HttpHeader = typeof HTTP_HEADERS[keyof typeof HTTP_HEADERS];\n\n/**\n * Content types\n */\nexport const CONTENT_TYPES = {\n JSON: 'application/json',\n FORM_DATA: 'multipart/form-data',\n FORM_URLENCODED: 'application/x-www-form-urlencoded',\n TEXT_PLAIN: 'text/plain',\n TEXT_STREAM: 'text/event-stream'\n} as const;\n\nexport type ContentType = typeof CONTENT_TYPES[keyof typeof CONTENT_TYPES];\n\n/**\n * HTTP status codes\n */\nexport const HTTP_STATUS = {\n // 2xx Success\n OK: 200,\n CREATED: 201,\n NO_CONTENT: 204,\n \n // 4xx Client Errors\n BAD_REQUEST: 400,\n UNAUTHORIZED: 401,\n FORBIDDEN: 403,\n NOT_FOUND: 404,\n CONFLICT: 409,\n TOO_MANY_REQUESTS: 429,\n RATE_LIMITED: 429, // Alias for Core SDK compatibility\n \n // 5xx Server Errors\n INTERNAL_SERVER_ERROR: 500,\n INTERNAL_ERROR: 500, // Alias for Admin SDK compatibility\n BAD_GATEWAY: 502,\n SERVICE_UNAVAILABLE: 503,\n GATEWAY_TIMEOUT: 504\n} as const;\n\nexport type HttpStatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];\n\n/**\n * Error codes for network errors\n */\nexport const ERROR_CODES = {\n CONNECTION_ABORTED: 'ECONNABORTED',\n TIMEOUT: 'ETIMEDOUT',\n CONNECTION_RESET: 'ECONNRESET',\n NETWORK_UNREACHABLE: 'ENETUNREACH',\n CONNECTION_REFUSED: 'ECONNREFUSED',\n HOST_NOT_FOUND: 'ENOTFOUND'\n} as const;\n\nexport type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES];\n\n/**\n * Default timeout values in milliseconds\n */\nexport const TIMEOUTS = {\n DEFAULT_REQUEST: 60000, // 60 seconds\n SHORT_REQUEST: 10000, // 10 seconds\n LONG_REQUEST: 300000, // 5 minutes\n STREAMING: 0 // No timeout for streaming\n} as const;\n\nexport type TimeoutValue = typeof TIMEOUTS[keyof typeof TIMEOUTS];\n\n/**\n * Retry configuration defaults\n */\nexport const RETRY_CONFIG = {\n DEFAULT_MAX_RETRIES: 3,\n INITIAL_DELAY: 1000, // 1 second\n MAX_DELAY: 30000, // 30 seconds\n BACKOFF_FACTOR: 2\n} as const;\n\nexport type RetryConfigValue = typeof RETRY_CONFIG[keyof typeof RETRY_CONFIG];","/**\n * SignalR hub connection states\n */\nexport enum HubConnectionState {\n Disconnected = 'Disconnected',\n Connecting = 'Connecting',\n Connected = 'Connected',\n Disconnecting = 'Disconnecting',\n Reconnecting = 'Reconnecting',\n}\n\n/**\n * SignalR logging levels\n */\nexport enum SignalRLogLevel {\n Trace = 0,\n Debug = 1,\n Information = 2,\n Warning = 3,\n Error = 4,\n Critical = 5,\n None = 6,\n}\n\n/**\n * HTTP transport types for SignalR\n */\nexport enum HttpTransportType {\n None = 0,\n WebSockets = 1,\n ServerSentEvents = 2,\n LongPolling = 4,\n}\n\n/**\n * Default transport configuration\n */\nexport const DefaultTransports =\n HttpTransportType.WebSockets |\n HttpTransportType.ServerSentEvents |\n HttpTransportType.LongPolling;\n\n/**\n * SignalR protocol types\n */\nexport enum SignalRProtocolType {\n /**\n * JSON protocol (default)\n */\n Json = 'json',\n /**\n * MessagePack binary protocol with compression\n */\n MessagePack = 'messagepack',\n}\n\n/**\n * Base SignalR connection options\n */\nexport interface SignalRConnectionOptions {\n /**\n * Logging level\n */\n logLevel?: SignalRLogLevel;\n \n /**\n * Transport types to use\n */\n transport?: HttpTransportType;\n \n /**\n * Headers to include with requests\n */\n headers?: Record;\n \n /**\n * Access token factory for authentication\n */\n accessTokenFactory?: () => string | Promise;\n \n /**\n * Close timeout in milliseconds\n */\n closeTimeout?: number;\n \n /**\n * Reconnection delay intervals in milliseconds\n */\n reconnectionDelay?: number[];\n \n /**\n * Server timeout in milliseconds\n */\n serverTimeout?: number;\n \n /**\n * Keep-alive interval in milliseconds\n */\n keepAliveInterval?: number;\n\n /**\n * Protocol to use for SignalR communication\n * @default SignalRProtocolType.Json\n */\n protocol?: SignalRProtocolType;\n}\n\n/**\n * Authentication configuration for SignalR connections\n */\nexport interface SignalRAuthConfig {\n /**\n * Authentication token or key\n */\n authToken: string;\n \n /**\n * Authentication type (e.g., 'master', 'virtual')\n */\n authType: 'master' | 'virtual';\n \n /**\n * Additional headers for authentication\n */\n additionalHeaders?: Record;\n}\n\n/**\n * SignalR hub method argument types for type safety\n */\nexport type SignalRPrimitive = string | number | boolean | null | undefined;\nexport type SignalRValue = SignalRPrimitive | SignalRArgs | SignalRPrimitive[];\nexport interface SignalRArgs {\n [key: string]: SignalRValue;\n}","import * as signalR from '@microsoft/signalr';\nimport {\n HubConnectionState,\n HttpTransportType,\n DefaultTransports,\n SignalRAuthConfig,\n SignalRConnectionOptions,\n SignalRLogLevel,\n SignalRProtocolType\n} from './types';\n\n// Lazy import for MessagePack protocol\nlet MessagePackHubProtocol: any;\n\n/**\n * Lazy loads the MessagePack protocol module\n */\nasync function loadMessagePackProtocol(): Promise {\n if (!MessagePackHubProtocol) {\n try {\n const msgpack = await import('@microsoft/signalr-protocol-msgpack');\n MessagePackHubProtocol = msgpack.MessagePackHubProtocol;\n return msgpack.MessagePackHubProtocol;\n } catch (error) {\n console.warn('MessagePack protocol not available, using JSON:', error);\n return null;\n }\n }\n return MessagePackHubProtocol;\n}\n\n/**\n * Base configuration for SignalR connections\n */\nexport interface BaseSignalRConfig {\n /**\n * Base URL for the SignalR hub\n */\n baseUrl: string;\n \n /**\n * Authentication configuration\n */\n auth: SignalRAuthConfig;\n \n /**\n * Connection options\n */\n options?: SignalRConnectionOptions;\n \n /**\n * User agent string\n */\n userAgent?: string;\n}\n\n/**\n * Base class for SignalR hub connections with automatic reconnection and error handling.\n * This abstract class provides common functionality for both Admin and Core SDKs.\n */\nexport abstract class BaseSignalRConnection {\n protected connection?: signalR.HubConnection;\n protected readonly config: BaseSignalRConfig;\n protected connectionReadyPromise: Promise;\n private connectionReadyResolve?: () => void;\n private connectionReadyReject?: (error: Error) => void;\n private disposed = false;\n\n /**\n * Gets the hub path for this connection type.\n */\n protected abstract get hubPath(): string;\n\n constructor(config: BaseSignalRConfig) {\n this.config = {\n ...config,\n baseUrl: config.baseUrl.replace(/\\/$/, '')\n };\n \n // Initialize the connection ready promise\n this.connectionReadyPromise = new Promise((resolve, reject) => {\n this.connectionReadyResolve = resolve;\n this.connectionReadyReject = reject;\n });\n }\n\n /**\n * Gets whether the connection is established and ready for use.\n */\n get isConnected(): boolean {\n return this.connection?.state === signalR.HubConnectionState.Connected;\n }\n\n /**\n * Gets the current connection state.\n */\n get state(): HubConnectionState {\n if (!this.connection) {\n return HubConnectionState.Disconnected;\n }\n\n switch (this.connection.state) {\n case signalR.HubConnectionState.Connected:\n return HubConnectionState.Connected;\n case signalR.HubConnectionState.Connecting:\n return HubConnectionState.Connecting;\n case signalR.HubConnectionState.Disconnected:\n return HubConnectionState.Disconnected;\n case signalR.HubConnectionState.Disconnecting:\n return HubConnectionState.Disconnecting;\n case signalR.HubConnectionState.Reconnecting:\n return HubConnectionState.Reconnecting;\n default:\n return HubConnectionState.Disconnected;\n }\n }\n\n /**\n * Event handlers\n */\n onConnected?: () => Promise;\n onDisconnected?: (error?: Error) => Promise;\n onReconnecting?: (error?: Error) => Promise;\n onReconnected?: (connectionId?: string) => Promise;\n\n /**\n * Establishes the SignalR connection.\n */\n protected async getConnection(): Promise {\n if (this.connection) {\n return this.connection;\n }\n\n const hubUrl = `${this.config.baseUrl}${this.hubPath}`;\n \n // Build connection options\n const connectionOptions: signalR.IHttpConnectionOptions = {\n accessTokenFactory: this.config.options?.accessTokenFactory || (() => this.config.auth.authToken),\n transport: this.mapTransportType(this.config.options?.transport || DefaultTransports),\n headers: this.buildHeaders(),\n withCredentials: false\n };\n \n // Build the connection\n const builder = new signalR.HubConnectionBuilder()\n .withUrl(hubUrl, connectionOptions)\n .withAutomaticReconnect(this.config.options?.reconnectionDelay || [0, 2000, 10000, 30000]);\n\n // Configure server timeout and keep-alive if specified\n if (this.config.options?.serverTimeout) {\n builder.withServerTimeout(this.config.options.serverTimeout);\n }\n \n if (this.config.options?.keepAliveInterval) {\n builder.withKeepAliveInterval(this.config.options.keepAliveInterval);\n }\n\n // Configure logging\n const logLevel = this.mapLogLevel(this.config.options?.logLevel || SignalRLogLevel.Information);\n builder.configureLogging(logLevel);\n\n // Configure protocol (JSON by default, MessagePack if specified)\n const protocolType = this.config.options?.protocol || SignalRProtocolType.Json;\n if (protocolType === SignalRProtocolType.MessagePack) {\n try {\n const MessagePackProtocol = await loadMessagePackProtocol();\n if (MessagePackProtocol) {\n builder.withHubProtocol(new MessagePackProtocol());\n console.warn('Using MessagePack protocol for SignalR connection');\n }\n } catch (error) {\n console.error('Failed to load MessagePack protocol, falling back to JSON:', error);\n // Continue with JSON (default) - graceful degradation\n }\n }\n\n this.connection = builder.build();\n\n // Set up event handlers\n this.connection.onclose(async (error) => {\n if (this.onDisconnected) {\n await this.onDisconnected(error);\n }\n });\n\n this.connection.onreconnecting(async (error) => {\n if (this.onReconnecting) {\n await this.onReconnecting(error);\n }\n });\n\n this.connection.onreconnected(async (connectionId) => {\n if (this.onReconnected) {\n await this.onReconnected(connectionId);\n }\n });\n\n // Configure hub-specific handlers\n this.configureHubHandlers(this.connection);\n\n try {\n await this.connection.start();\n \n if (this.connectionReadyResolve) {\n this.connectionReadyResolve();\n }\n \n if (this.onConnected) {\n await this.onConnected();\n }\n } catch (error) {\n if (this.connectionReadyReject) {\n this.connectionReadyReject(error as Error);\n }\n throw error;\n }\n\n return this.connection;\n }\n\n /**\n * Configures hub-specific event handlers. Override in derived classes.\n */\n protected abstract configureHubHandlers(connection: signalR.HubConnection): void;\n\n /**\n * Maps transport type enum to SignalR transport.\n */\n protected mapTransportType(transport: HttpTransportType): signalR.HttpTransportType {\n let result = signalR.HttpTransportType.None;\n \n if (transport & HttpTransportType.WebSockets) {\n result |= signalR.HttpTransportType.WebSockets;\n }\n if (transport & HttpTransportType.ServerSentEvents) {\n result |= signalR.HttpTransportType.ServerSentEvents;\n }\n if (transport & HttpTransportType.LongPolling) {\n result |= signalR.HttpTransportType.LongPolling;\n }\n \n return result;\n }\n\n /**\n * Maps log level enum to SignalR log level.\n */\n protected mapLogLevel(level: SignalRLogLevel): signalR.LogLevel {\n switch (level) {\n case SignalRLogLevel.Trace:\n return signalR.LogLevel.Trace;\n case SignalRLogLevel.Debug:\n return signalR.LogLevel.Debug;\n case SignalRLogLevel.Information:\n return signalR.LogLevel.Information;\n case SignalRLogLevel.Warning:\n return signalR.LogLevel.Warning;\n case SignalRLogLevel.Error:\n return signalR.LogLevel.Error;\n case SignalRLogLevel.Critical:\n return signalR.LogLevel.Critical;\n case SignalRLogLevel.None:\n return signalR.LogLevel.None;\n default:\n return signalR.LogLevel.Information;\n }\n }\n\n /**\n * Builds headers for the connection based on configuration.\n */\n private buildHeaders(): Record {\n const headers: Record = {\n 'User-Agent': this.config.userAgent || 'Conduit-Node-Client/1.0.0',\n ...this.config.options?.headers\n };\n\n // Add authentication-specific headers\n if (this.config.auth.authType === 'master' && this.config.auth.additionalHeaders) {\n Object.assign(headers, this.config.auth.additionalHeaders);\n }\n\n return headers;\n }\n\n /**\n * Waits for the connection to be ready.\n */\n public async waitForReady(): Promise {\n return this.connectionReadyPromise;\n }\n\n /**\n * Invokes a method on the hub with proper error handling.\n */\n protected async invoke(methodName: string, ...args: unknown[]): Promise {\n if (this.disposed) {\n throw new Error('Connection has been disposed');\n }\n\n const connection = await this.getConnection();\n \n try {\n return await connection.invoke(methodName, ...args);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n throw new Error(`SignalR invoke error for ${methodName}: ${errorMessage}`);\n }\n }\n\n /**\n * Sends a message to the hub without expecting a response.\n */\n protected async send(methodName: string, ...args: unknown[]): Promise {\n if (this.disposed) {\n throw new Error('Connection has been disposed');\n }\n\n const connection = await this.getConnection();\n \n try {\n await connection.send(methodName, ...args);\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error);\n throw new Error(`SignalR send error for ${methodName}: ${errorMessage}`);\n }\n }\n\n /**\n * Disconnects the SignalR connection.\n */\n public async disconnect(): Promise {\n if (this.connection && this.connection.state !== signalR.HubConnectionState.Disconnected) {\n await this.connection.stop();\n this.connection = undefined;\n \n // Reset the connection ready promise\n this.connectionReadyPromise = new Promise((resolve, reject) => {\n this.connectionReadyResolve = resolve;\n this.connectionReadyReject = reject;\n });\n }\n }\n\n /**\n * Disposes of the connection and cleans up resources.\n */\n public async dispose(): Promise {\n this.disposed = true;\n await this.disconnect();\n this.connectionReadyResolve = undefined;\n this.connectionReadyReject = undefined;\n }\n}","/**\n * Logger interface for client logging\n */\nexport interface Logger {\n debug(message: string, ...args: unknown[]): void;\n info(message: string, ...args: unknown[]): void;\n warn(message: string, ...args: unknown[]): void;\n error(message: string, ...args: unknown[]): void;\n}\n\n/**\n * Cache provider interface for client-side caching\n */\nexport interface CacheProvider {\n get(key: string): Promise;\n set(key: string, value: T, ttl?: number): Promise;\n delete(key: string): Promise;\n clear(): Promise;\n}\n\n/**\n * Base retry configuration interface\n * \n * Note: The Admin and Core SDKs have different retry strategies:\n * - Admin SDK uses simple fixed delay retry\n * - Core SDK uses exponential backoff\n * \n * This base interface supports both patterns.\n */\nexport interface RetryConfig {\n /**\n * Maximum number of retry attempts\n */\n maxRetries: number;\n \n /**\n * For Admin SDK: Fixed delay between retries in milliseconds\n * For Core SDK: Initial delay for exponential backoff\n */\n retryDelay?: number;\n \n /**\n * For Core SDK: Initial delay for exponential backoff\n */\n initialDelay?: number;\n \n /**\n * For Core SDK: Maximum delay between retries\n */\n maxDelay?: number;\n \n /**\n * For Core SDK: Backoff multiplication factor\n */\n factor?: number;\n \n /**\n * Custom retry condition function\n */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * HTTP error class\n */\nexport class HttpError extends Error {\n public code?: string;\n public response?: {\n status: number;\n data: unknown;\n headers: Record;\n };\n public request?: unknown;\n public config?: {\n url?: string;\n method?: string;\n _retry?: number;\n };\n\n constructor(message: string, code?: string) {\n super(message);\n this.name = 'HttpError';\n this.code = code;\n }\n}\n\n/**\n * Request configuration information\n */\nexport interface RequestConfigInfo {\n method: string;\n url: string;\n headers: Record;\n data?: unknown;\n params?: Record;\n}\n\n/**\n * Response information\n */\nexport interface ResponseInfo {\n status: number;\n statusText: string;\n headers: Record;\n data: unknown;\n config: RequestConfigInfo;\n}\n\n/**\n * Base client lifecycle callbacks\n */\nexport interface ClientLifecycleCallbacks {\n /**\n * Callback invoked on any error\n */\n onError?: (error: Error) => void;\n \n /**\n * Callback invoked before each request\n */\n onRequest?: (config: RequestConfigInfo) => void | Promise;\n \n /**\n * Callback invoked after each response\n */\n onResponse?: (response: ResponseInfo) => void | Promise;\n}\n\n/**\n * Base client configuration options\n */\nexport interface BaseClientOptions extends ClientLifecycleCallbacks {\n /**\n * Request timeout in milliseconds\n */\n timeout?: number;\n \n /**\n * Retry configuration\n */\n retries?: number | RetryConfig;\n \n /**\n * Logger instance for client logging\n */\n logger?: Logger;\n \n /**\n * Cache provider for response caching\n */\n cache?: CacheProvider;\n \n /**\n * Custom headers to include with all requests\n */\n headers?: Record;\n \n /**\n * Custom retry delays in milliseconds (overrides retry config)\n * @default [1000, 2000, 4000, 8000, 16000]\n */\n retryDelay?: number[];\n \n /**\n * Custom function to validate response status\n */\n validateStatus?: (status: number) => boolean;\n \n /**\n * Enable debug mode\n */\n debug?: boolean;\n}","/**\n * Retry strategy types and utilities for SDK HTTP clients\n * Supports both fixed delay (Admin SDK) and exponential backoff (Gateway SDK) patterns\n */\n\n/**\n * Type of retry strategy to use\n */\nexport enum RetryStrategyType {\n /** Fixed delay between retries (Admin SDK pattern) */\n FIXED_DELAY = 'fixed_delay',\n /** Exponential backoff with optional jitter (Gateway SDK pattern) */\n EXPONENTIAL_BACKOFF = 'exponential_backoff',\n /** Custom array of delays */\n CUSTOM_DELAYS = 'custom_delays'\n}\n\n/**\n * Fixed delay retry configuration\n * Used by Admin SDK for simple retry patterns\n */\nexport interface FixedDelayConfig {\n type: RetryStrategyType.FIXED_DELAY;\n /** Maximum number of retry attempts */\n maxRetries: number;\n /** Delay between retries in milliseconds */\n delayMs: number;\n /** Optional custom condition to determine if error is retryable */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * Exponential backoff retry configuration\n * Used by Gateway SDK for sophisticated retry patterns\n */\nexport interface ExponentialBackoffConfig {\n type: RetryStrategyType.EXPONENTIAL_BACKOFF;\n /** Maximum number of retry attempts */\n maxRetries: number;\n /** Initial delay in milliseconds */\n initialDelayMs: number;\n /** Maximum delay cap in milliseconds */\n maxDelayMs: number;\n /** Multiplication factor for each retry */\n factor: number;\n /** Whether to add random jitter to prevent thundering herd */\n jitter?: boolean;\n /** Optional custom condition to determine if error is retryable */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * Custom delays retry configuration\n * Allows specifying exact delay for each retry attempt\n */\nexport interface CustomDelaysConfig {\n type: RetryStrategyType.CUSTOM_DELAYS;\n /** Array of delays in milliseconds for each retry attempt */\n delays: number[];\n /** Optional custom condition to determine if error is retryable */\n retryCondition?: (error: unknown) => boolean;\n}\n\n/**\n * Union type for all retry strategy configurations\n */\nexport type RetryStrategy = FixedDelayConfig | ExponentialBackoffConfig | CustomDelaysConfig;\n\n/**\n * Calculate the delay for a retry attempt based on the strategy\n * @param strategy - The retry strategy configuration\n * @param attempt - The current attempt number (1-based)\n * @returns Delay in milliseconds before the next retry\n */\nexport function calculateRetryDelay(\n strategy: RetryStrategy,\n attempt: number\n): number {\n switch (strategy.type) {\n case RetryStrategyType.FIXED_DELAY:\n return strategy.delayMs;\n\n case RetryStrategyType.EXPONENTIAL_BACKOFF: {\n const delay = Math.min(\n strategy.initialDelayMs * Math.pow(strategy.factor, attempt - 1),\n strategy.maxDelayMs\n );\n if (strategy.jitter) {\n // Add up to 1 second of random jitter\n return delay + Math.random() * 1000;\n }\n return delay;\n }\n\n case RetryStrategyType.CUSTOM_DELAYS: {\n // Use the last delay if attempt exceeds array length\n const index = Math.min(attempt - 1, strategy.delays.length - 1);\n return strategy.delays[index];\n }\n }\n}\n\n/**\n * Get the maximum number of retries for a strategy\n * @param strategy - The retry strategy configuration\n * @returns Maximum number of retry attempts\n */\nexport function getMaxRetries(strategy: RetryStrategy): number {\n switch (strategy.type) {\n case RetryStrategyType.FIXED_DELAY:\n case RetryStrategyType.EXPONENTIAL_BACKOFF:\n return strategy.maxRetries;\n case RetryStrategyType.CUSTOM_DELAYS:\n return strategy.delays.length;\n }\n}\n\n/**\n * Check if an error should be retried based on the strategy's condition\n * @param strategy - The retry strategy configuration\n * @param error - The error to check\n * @returns Whether the error should trigger a retry\n */\nexport function shouldRetryWithStrategy(\n strategy: RetryStrategy,\n error: unknown\n): boolean {\n if (strategy.retryCondition) {\n return strategy.retryCondition(error);\n }\n // Default: don't retry if no condition specified\n return false;\n}\n\n/**\n * Default retry strategies for each SDK type\n */\nexport const DEFAULT_RETRY_STRATEGIES = {\n /** Gateway SDK default: exponential backoff with jitter */\n gateway: {\n type: RetryStrategyType.EXPONENTIAL_BACKOFF,\n maxRetries: 3,\n initialDelayMs: 1000,\n maxDelayMs: 30000,\n factor: 2,\n jitter: true,\n } as ExponentialBackoffConfig,\n\n /** Admin SDK default: fixed delay */\n admin: {\n type: RetryStrategyType.FIXED_DELAY,\n maxRetries: 3,\n delayMs: 1000,\n } as FixedDelayConfig,\n};\n","/**\n * Abstract base API client providing common HTTP functionality\n *\n * SDK-specific clients extend this class and implement:\n * - getAuthHeaders(): Returns authentication headers\n * - getDefaultRetryStrategy(): Returns default retry strategy\n *\n * Template methods that can be overridden:\n * - handleErrorResponse(): SDK-specific error parsing\n * - shouldRetry(): SDK-specific retry logic\n * - getRetryDelay(): SDK-specific delay calculation\n */\n\nimport type { BaseApiClientConfig } from './base-client-config';\nimport type { Logger, CacheProvider, RequestConfigInfo, ResponseInfo } from './types';\nimport type { RetryStrategy } from './retry-strategy';\nimport { calculateRetryDelay, getMaxRetries } from './retry-strategy';\nimport { ResponseParser } from '../http/parser';\nimport { HttpMethod, type ExtendedRequestInit } from '../http/types';\nimport { HTTP_HEADERS, CONTENT_TYPES } from '../http/constants';\nimport { ConduitError } from '../errors';\n\n/**\n * Request options for individual requests\n */\nexport interface BaseRequestOptions {\n /** Additional headers for this request */\n headers?: Record;\n /** AbortSignal for request cancellation */\n signal?: AbortSignal;\n /** Request timeout in milliseconds (overrides client default) */\n timeout?: number;\n /** Expected response type */\n responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';\n}\n\n/**\n * Abstract base API client providing common HTTP functionality\n *\n * Both Gateway SDK and Admin SDK extend this class.\n */\nexport abstract class BaseApiClient {\n /** Base URL for all requests (without trailing slash) */\n protected readonly baseUrl: string;\n /** Default timeout in milliseconds */\n protected readonly timeout: number;\n /** Default headers included with all requests */\n protected readonly defaultHeaders: Record;\n /** Retry strategy configuration */\n protected readonly retryStrategy: RetryStrategy;\n /** Enable debug logging */\n protected readonly debug: boolean;\n\n // Lifecycle callbacks\n protected readonly onError?: (error: Error) => void;\n protected readonly onRequest?: (config: RequestConfigInfo) => void | Promise;\n protected readonly onResponse?: (response: ResponseInfo) => void | Promise;\n\n // Optional providers (Admin SDK uses these, Gateway SDK may not)\n protected readonly logger?: Logger;\n protected readonly cache?: CacheProvider;\n\n constructor(config: BaseApiClientConfig) {\n this.baseUrl = config.baseUrl.replace(/\\/$/, '');\n this.timeout = config.timeout ?? 60000;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.retryStrategy = config.retryStrategy ?? this.getDefaultRetryStrategy();\n this.debug = config.debug ?? false;\n\n this.onError = config.onError;\n this.onRequest = config.onRequest;\n this.onResponse = config.onResponse;\n this.logger = config.logger;\n this.cache = config.cache;\n }\n\n // ============================================================================\n // Abstract Methods - Must be implemented by SDK-specific clients\n // ============================================================================\n\n /**\n * Returns authentication headers for this SDK\n *\n * Gateway SDK returns: { Authorization: 'Bearer ...' }\n * Admin SDK returns: { 'X-Master-Key': '...' }\n */\n protected abstract getAuthHeaders(): Record;\n\n /**\n * Returns default retry strategy for this SDK\n *\n * Gateway SDK uses exponential backoff with jitter\n * Admin SDK uses fixed delay\n */\n protected abstract getDefaultRetryStrategy(): RetryStrategy;\n\n // ============================================================================\n // Template Methods - Can be overridden by SDK-specific clients\n // ============================================================================\n\n /**\n * Transform error response into appropriate error type\n * Subclasses can override for SDK-specific error handling\n *\n * @param response - The failed Response object\n * @returns An Error to throw\n */\n protected async handleErrorResponse(response: Response): Promise {\n let errorData: unknown;\n try {\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n errorData = await response.json();\n }\n } catch {\n errorData = {};\n }\n\n // Default implementation - subclasses can override for richer error handling\n return new ConduitError(\n `HTTP ${response.status}: ${response.statusText}`,\n response.status,\n `HTTP_${response.status}`,\n { data: errorData }\n );\n }\n\n /**\n * Determine if an error should be retried\n * Subclasses can override for SDK-specific retry logic\n *\n * @param error - The error that occurred\n * @param attempt - Current attempt number (1-based)\n * @returns Whether to retry the request\n */\n protected shouldRetry(error: unknown, attempt: number): boolean {\n const maxRetries = getMaxRetries(this.retryStrategy);\n if (attempt > maxRetries) return false;\n\n // Check custom retry condition if provided\n if (this.retryStrategy.retryCondition) {\n return this.retryStrategy.retryCondition(error);\n }\n\n // Default retry logic\n if (error instanceof ConduitError) {\n // Retry rate limits and server errors\n return error.statusCode === 429 || error.statusCode >= 500;\n }\n\n if (error instanceof Error) {\n // Network errors are retryable\n return (\n error.name === 'AbortError' ||\n error.message.includes('network') ||\n error.message.includes('fetch')\n );\n }\n\n return false;\n }\n\n /**\n * Calculate delay for a retry attempt\n * Subclasses can override for special cases (e.g., retry-after headers)\n *\n * @param error - The error that triggered the retry\n * @param attempt - Current attempt number (1-based)\n * @returns Delay in milliseconds before next retry\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n protected getRetryDelay(_error: unknown, attempt: number): number {\n return calculateRetryDelay(this.retryStrategy, attempt);\n }\n\n // ============================================================================\n // HTTP Methods\n // ============================================================================\n\n /**\n * Main request method with retry logic\n */\n protected async request(\n url: string,\n options: BaseRequestOptions & { method?: HttpMethod; body?: TRequest } = {}\n ): Promise {\n const fullUrl = this.buildUrl(url);\n const controller = new AbortController();\n\n const timeoutMs = options.timeout ?? this.timeout;\n const timeoutId = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const requestInfo: RequestConfigInfo = {\n method: options.method ?? HttpMethod.GET,\n url: fullUrl,\n headers: this.buildHeaders(options.headers),\n data: options.body,\n };\n\n // Call onRequest hook if provided\n if (this.onRequest) {\n await this.onRequest(requestInfo);\n }\n\n this.log('debug', `API Request: ${requestInfo.method} ${requestInfo.url}`);\n\n const response = await this.executeWithRetry(\n fullUrl,\n {\n method: requestInfo.method,\n headers: requestInfo.headers,\n body: options.body ? JSON.stringify(options.body) : undefined,\n signal: options.signal ?? controller.signal,\n responseType: options.responseType,\n timeout: timeoutMs,\n }\n );\n\n return response;\n } finally {\n clearTimeout(timeoutId);\n }\n }\n\n /**\n * Type-safe GET request\n */\n protected async get(\n url: string,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, { ...options, method: HttpMethod.GET });\n }\n\n /**\n * Type-safe POST request\n */\n protected async post(\n url: string,\n data?: TRequest,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, {\n ...options,\n method: HttpMethod.POST,\n body: data,\n });\n }\n\n /**\n * Type-safe PUT request\n */\n protected async put(\n url: string,\n data?: TRequest,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, {\n ...options,\n method: HttpMethod.PUT,\n body: data,\n });\n }\n\n /**\n * Type-safe PATCH request\n */\n protected async patch(\n url: string,\n data?: TRequest,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, {\n ...options,\n method: HttpMethod.PATCH,\n body: data,\n });\n }\n\n /**\n * Type-safe DELETE request\n */\n protected async delete(\n url: string,\n options?: BaseRequestOptions\n ): Promise {\n return this.request(url, { ...options, method: HttpMethod.DELETE });\n }\n\n // ============================================================================\n // Internal Methods\n // ============================================================================\n\n /**\n * Execute request with retry logic\n */\n private async executeWithRetry(\n url: string,\n init: ExtendedRequestInit,\n attempt: number = 1\n ): Promise {\n try {\n const response = await fetch(url, ResponseParser.cleanRequestInit(init));\n\n this.log('debug', `API Response: ${response.status} ${response.statusText}`);\n\n // Build response info for callback\n const headers: Record = {};\n response.headers.forEach((value, key) => {\n headers[key] = value;\n });\n\n // Call onResponse hook if provided\n if (this.onResponse) {\n const responseInfo: ResponseInfo = {\n status: response.status,\n statusText: response.statusText,\n headers,\n data: undefined,\n config: {\n url,\n method: (init.method as string) ?? HttpMethod.GET,\n headers: (init.headers as Record) ?? {},\n },\n };\n await this.onResponse(responseInfo);\n }\n\n if (!response.ok) {\n const error = await this.handleErrorResponse(response);\n throw error;\n }\n\n // Handle empty responses\n const contentLength = response.headers.get('content-length');\n if (contentLength === '0' || response.status === 204) {\n return undefined as TResponse;\n }\n\n return await ResponseParser.parse(response, init.responseType);\n } catch (error) {\n if (this.shouldRetry(error, attempt)) {\n const delay = this.getRetryDelay(error, attempt);\n this.log('debug', `Retrying request (attempt ${attempt + 1}) after ${delay}ms`);\n\n await this.sleep(delay);\n return this.executeWithRetry(url, init, attempt + 1);\n }\n\n // Call error handler and rethrow\n if (this.onError && error instanceof Error) {\n this.onError(error);\n }\n throw error;\n }\n }\n\n /**\n * Build full URL from path\n */\n private buildUrl(path: string): string {\n // If path is already a full URL, return it\n if (path.startsWith('http://') || path.startsWith('https://')) {\n return path;\n }\n\n // Ensure path starts with /\n const cleanPath = path.startsWith('/') ? path : `/${path}`;\n return `${this.baseUrl}${cleanPath}`;\n }\n\n /**\n * Build headers including auth, defaults, and additional headers\n */\n private buildHeaders(additionalHeaders?: Record): Record {\n return {\n [HTTP_HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON,\n ...this.getAuthHeaders(),\n ...this.defaultHeaders,\n ...additionalHeaders,\n };\n }\n\n /**\n * Log a message using the configured logger or console in debug mode\n */\n protected log(\n level: 'debug' | 'info' | 'warn' | 'error',\n message: string,\n ...args: unknown[]\n ): void {\n if (this.logger?.[level]) {\n this.logger[level](message, ...args);\n } else if (this.debug && level === 'debug') {\n console.warn(`[SDK] ${message}`, ...args);\n }\n }\n\n /**\n * Sleep for a specified duration\n */\n private sleep(ms: number): Promise {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n\n // ============================================================================\n // Caching Utilities (Optional - only active if cache provider is configured)\n // ============================================================================\n\n /**\n * Get a value from cache\n * Returns null if cache is not configured or key is not found\n */\n protected async getFromCache(key: string): Promise {\n if (!this.cache) return null;\n\n try {\n const cached = await this.cache.get(key);\n if (cached) {\n this.log('debug', `Cache hit for key: ${key}`);\n return cached;\n }\n } catch (error) {\n this.log('error', 'Cache get error:', error);\n }\n\n return null;\n }\n\n /**\n * Set a value in cache\n * No-op if cache is not configured\n */\n protected async setCache(key: string, value: unknown, ttl?: number): Promise {\n if (!this.cache) return;\n\n try {\n await this.cache.set(key, value, ttl);\n this.log('debug', `Cache set for key: ${key}`);\n } catch (error) {\n this.log('error', 'Cache set error:', error);\n }\n }\n\n /**\n * Execute a function with caching\n * Returns cached value if available, otherwise executes function and caches result\n */\n protected async withCache(\n cacheKey: string,\n fn: () => Promise,\n ttl?: number\n ): Promise {\n const cached = await this.getFromCache(cacheKey);\n if (cached !== null) {\n return cached;\n }\n\n const result = await fn();\n await this.setCache(cacheKey, result, ttl);\n\n return result;\n }\n\n /**\n * Generate a cache key from resource and identifiers\n */\n protected getCacheKey(\n resource: string,\n ...identifiers: (string | number | Record | undefined)[]\n ): string {\n const parts = identifiers\n .filter((id) => id !== undefined)\n .map((id) => (typeof id === 'object' ? JSON.stringify(id) : String(id)));\n return `${resource}:${parts.join(':')}`;\n }\n}\n","/**\n * Circuit breaker types and interfaces\n *\n * Provides types for implementing the circuit breaker pattern to prevent\n * cascading failures and protect against sustained service degradation.\n */\n\n/**\n * Circuit breaker states following the standard pattern\n */\nexport enum CircuitState {\n /** Normal operation - requests pass through, failures tracked */\n CLOSED = 'closed',\n /** Circuit tripped - requests are blocked/rejected immediately */\n OPEN = 'open',\n /** Testing recovery - limited requests allowed to test if service recovered */\n HALF_OPEN = 'half_open'\n}\n\n/**\n * Configuration options for the circuit breaker\n */\nexport interface CircuitBreakerConfig {\n /** Number of consecutive failures to trip the circuit (default: 3) */\n failureThreshold?: number;\n\n /** Time window in milliseconds for counting failures (default: 60000) */\n failureWindowMs?: number;\n\n /** Time in milliseconds to wait before transitioning from OPEN to HALF_OPEN (default: 30000) */\n resetTimeoutMs?: number;\n\n /** Number of successful requests in HALF_OPEN to close circuit (default: 1) */\n successThreshold?: number;\n\n /** Enable debug logging (default: false) */\n enableLogging?: boolean;\n\n /** Custom function to determine if an error should count as a failure */\n shouldCountAsFailure?: (error: unknown) => boolean;\n}\n\n/**\n * Statistics about the circuit breaker state\n */\nexport interface CircuitBreakerStats {\n /** Current state of the circuit */\n state: CircuitState;\n\n /** Number of consecutive failures in current window */\n consecutiveFailures: number;\n\n /** Total failures since last reset */\n totalFailures: number;\n\n /** Total successes since last reset */\n totalSuccesses: number;\n\n /** Timestamp when circuit was opened (null if closed) */\n circuitOpenedAt: number | null;\n\n /** Time remaining until HALF_OPEN transition in ms (null if not OPEN) */\n timeUntilHalfOpen: number | null;\n\n /** Timestamp of last failure */\n lastFailureAt: number | null;\n\n /** Timestamp of last success */\n lastSuccessAt: number | null;\n\n /** Number of requests rejected while OPEN */\n rejectedRequests: number;\n}\n\n/**\n * Callbacks for circuit breaker state changes\n */\nexport interface CircuitBreakerCallbacks {\n /** Called when circuit transitions to OPEN state */\n onOpen?: (stats: CircuitBreakerStats, error: unknown) => void;\n\n /** Called when circuit transitions to HALF_OPEN state */\n onHalfOpen?: (stats: CircuitBreakerStats) => void;\n\n /** Called when circuit transitions to CLOSED state */\n onClose?: (stats: CircuitBreakerStats) => void;\n\n /** Called when a request is rejected due to OPEN circuit */\n onRejected?: (stats: CircuitBreakerStats) => void;\n\n /** Called on any state change */\n onStateChange?: (oldState: CircuitState, newState: CircuitState, stats: CircuitBreakerStats) => void;\n}\n","/**\n * Circuit breaker error types\n */\n\nimport { ConduitError } from '../errors';\nimport type { CircuitState, CircuitBreakerStats } from './types';\n\n/**\n * Error thrown when circuit breaker is open and request is rejected\n */\nexport class CircuitBreakerOpenError extends ConduitError {\n /** Current circuit breaker state */\n public readonly circuitState: CircuitState;\n\n /** Time until circuit transitions to HALF_OPEN (milliseconds) */\n public readonly timeUntilHalfOpen: number | null;\n\n /** Circuit breaker statistics at time of rejection */\n public readonly stats: CircuitBreakerStats;\n\n constructor(\n message: string,\n stats: CircuitBreakerStats,\n timeUntilHalfOpen: number | null\n ) {\n super(message, 503, 'CIRCUIT_BREAKER_OPEN', {\n circuitState: stats.state,\n timeUntilHalfOpen,\n consecutiveFailures: stats.consecutiveFailures,\n totalFailures: stats.totalFailures\n });\n\n this.circuitState = stats.state;\n this.timeUntilHalfOpen = timeUntilHalfOpen;\n this.stats = stats;\n }\n}\n\n/**\n * Type guard for CircuitBreakerOpenError\n */\nexport function isCircuitBreakerOpenError(error: unknown): error is CircuitBreakerOpenError {\n return error instanceof CircuitBreakerOpenError;\n}\n","/**\n * Circuit breaker implementation for preventing cascading failures\n *\n * Implements the circuit breaker pattern with three states:\n * - CLOSED: Normal operation, counting failures\n * - OPEN: Circuit tripped, rejecting requests\n * - HALF_OPEN: Testing recovery with limited requests\n */\n\nimport { CircuitState } from './types';\nimport type { CircuitBreakerConfig, CircuitBreakerStats, CircuitBreakerCallbacks } from './types';\nimport { CircuitBreakerOpenError } from './errors';\n\n/**\n * Default configuration values matching Issue #896 requirements\n */\nconst DEFAULT_CONFIG: Required> = {\n failureThreshold: 3,\n failureWindowMs: 60000, // 60 seconds\n resetTimeoutMs: 30000, // 30 seconds\n successThreshold: 1,\n enableLogging: false\n};\n\ninterface FailureRecord {\n timestamp: number;\n error: unknown;\n}\n\n/**\n * Circuit breaker implementation for preventing cascading failures\n *\n * State machine:\n * - CLOSED: Normal operation, counting failures\n * - OPEN: Circuit tripped, rejecting requests\n * - HALF_OPEN: Testing recovery with limited requests\n */\nexport class CircuitBreaker {\n private readonly config: Required> &\n Pick;\n private readonly callbacks: CircuitBreakerCallbacks;\n\n // State tracking\n private state: CircuitState = CircuitState.CLOSED;\n private failures: FailureRecord[] = [];\n private halfOpenSuccesses: number = 0;\n\n // Statistics\n private totalFailures: number = 0;\n private totalSuccesses: number = 0;\n private rejectedRequests: number = 0;\n private circuitOpenedAt: number | null = null;\n private lastFailureAt: number | null = null;\n private lastSuccessAt: number | null = null;\n\n constructor(\n config: CircuitBreakerConfig = {},\n callbacks: CircuitBreakerCallbacks = {}\n ) {\n this.config = {\n ...DEFAULT_CONFIG,\n ...config\n };\n this.callbacks = callbacks;\n }\n\n /**\n * Get current state of the circuit\n * Automatically transitions OPEN -> HALF_OPEN after timeout\n */\n getState(): CircuitState {\n // Check if OPEN circuit should transition to HALF_OPEN\n if (this.state === CircuitState.OPEN && this.circuitOpenedAt !== null) {\n const elapsed = Date.now() - this.circuitOpenedAt;\n if (elapsed >= this.config.resetTimeoutMs) {\n this.transitionTo(CircuitState.HALF_OPEN);\n }\n }\n return this.state;\n }\n\n /**\n * Get circuit breaker statistics\n */\n getStats(): CircuitBreakerStats {\n const currentState = this.getState();\n return {\n state: currentState,\n consecutiveFailures: this.getConsecutiveFailuresInWindow(),\n totalFailures: this.totalFailures,\n totalSuccesses: this.totalSuccesses,\n circuitOpenedAt: this.circuitOpenedAt,\n timeUntilHalfOpen: this.calculateTimeUntilHalfOpen(),\n lastFailureAt: this.lastFailureAt,\n lastSuccessAt: this.lastSuccessAt,\n rejectedRequests: this.rejectedRequests\n };\n }\n\n /**\n * Check if a request can proceed\n * Returns true if circuit is CLOSED or HALF_OPEN\n */\n canExecute(): boolean {\n const state = this.getState();\n return state !== CircuitState.OPEN;\n }\n\n /**\n * Check if request should proceed, throwing if circuit is open\n * @throws CircuitBreakerOpenError if circuit is OPEN\n */\n checkOpen(): void {\n const state = this.getState();\n if (state === CircuitState.OPEN) {\n this.rejectedRequests++;\n const stats = this.getStats();\n this.callbacks.onRejected?.(stats);\n\n throw new CircuitBreakerOpenError(\n `Circuit breaker is open. Try again in ${Math.ceil((stats.timeUntilHalfOpen ?? 0) / 1000)} seconds.`,\n stats,\n stats.timeUntilHalfOpen\n );\n }\n }\n\n /**\n * Record a successful request\n */\n recordSuccess(): void {\n this.totalSuccesses++;\n this.lastSuccessAt = Date.now();\n\n const currentState = this.getState();\n\n if (currentState === CircuitState.HALF_OPEN) {\n this.halfOpenSuccesses++;\n this.log('debug', `Half-open success ${this.halfOpenSuccesses}/${this.config.successThreshold}`);\n\n if (this.halfOpenSuccesses >= this.config.successThreshold) {\n this.transitionTo(CircuitState.CLOSED);\n }\n } else if (currentState === CircuitState.CLOSED) {\n // Clear failure history on success in CLOSED state\n this.failures = [];\n }\n }\n\n /**\n * Record a failed request\n */\n recordFailure(error: unknown): void {\n // Check if this error should count as a failure\n if (this.config.shouldCountAsFailure && !this.config.shouldCountAsFailure(error)) {\n this.log('debug', 'Error not counted as failure by custom filter');\n return;\n }\n\n const now = Date.now();\n this.totalFailures++;\n this.lastFailureAt = now;\n\n const currentState = this.getState();\n\n if (currentState === CircuitState.HALF_OPEN) {\n // Any failure in HALF_OPEN immediately reopens the circuit\n this.log('warn', 'Failure in half-open state, reopening circuit');\n this.transitionTo(CircuitState.OPEN, error);\n return;\n }\n\n if (currentState === CircuitState.CLOSED) {\n // Add to failure history\n this.failures.push({ timestamp: now, error });\n\n // Clean up old failures outside the window\n this.pruneOldFailures();\n\n // Check if we should trip the circuit\n const consecutiveFailures = this.getConsecutiveFailuresInWindow();\n this.log('debug', `Consecutive failures: ${consecutiveFailures}/${this.config.failureThreshold}`);\n\n if (consecutiveFailures >= this.config.failureThreshold) {\n this.transitionTo(CircuitState.OPEN, error);\n }\n }\n }\n\n /**\n * Manually reset the circuit to CLOSED state\n * Use with caution - typically for testing or admin override\n */\n reset(): void {\n this.log('info', 'Circuit manually reset');\n this.transitionTo(CircuitState.CLOSED);\n this.failures = [];\n this.totalFailures = 0;\n this.totalSuccesses = 0;\n this.rejectedRequests = 0;\n }\n\n // Private methods\n\n private transitionTo(newState: CircuitState, triggerError?: unknown): void {\n const oldState = this.state;\n if (oldState === newState) return;\n\n this.state = newState;\n const stats = this.getStats();\n\n this.log('info', `Circuit state change: ${oldState} -> ${newState}`);\n\n switch (newState) {\n case CircuitState.OPEN:\n this.circuitOpenedAt = Date.now();\n this.halfOpenSuccesses = 0;\n this.callbacks.onOpen?.(stats, triggerError);\n break;\n\n case CircuitState.HALF_OPEN:\n this.halfOpenSuccesses = 0;\n this.callbacks.onHalfOpen?.(stats);\n break;\n\n case CircuitState.CLOSED:\n this.circuitOpenedAt = null;\n this.failures = [];\n this.halfOpenSuccesses = 0;\n this.callbacks.onClose?.(stats);\n break;\n }\n\n this.callbacks.onStateChange?.(oldState, newState, stats);\n }\n\n private pruneOldFailures(): void {\n const cutoff = Date.now() - this.config.failureWindowMs;\n this.failures = this.failures.filter(f => f.timestamp >= cutoff);\n }\n\n private getConsecutiveFailuresInWindow(): number {\n this.pruneOldFailures();\n return this.failures.length;\n }\n\n private calculateTimeUntilHalfOpen(): number | null {\n if (this.state !== CircuitState.OPEN || this.circuitOpenedAt === null) {\n return null;\n }\n\n const elapsed = Date.now() - this.circuitOpenedAt;\n const remaining = this.config.resetTimeoutMs - elapsed;\n return remaining > 0 ? remaining : 0;\n }\n\n private log(_level: 'debug' | 'info' | 'warn' | 'error', message: string): void {\n if (this.config.enableLogging) {\n console.warn(`[CircuitBreaker] ${message}`);\n }\n }\n}\n"],"mappings":";AAOO,IAAK,kBAAL,kBAAKA,qBAAL;AACL,EAAAA,iBAAA,UAAO;AACP,EAAAA,iBAAA,YAAS;AACT,EAAAA,iBAAA,sBAAmB;AACnB,EAAAA,iBAAA,gBAAa;AACb,EAAAA,iBAAA,qBAAkB;AAClB,EAAAA,iBAAA,yBAAsB;AACtB,EAAAA,iBAAA,oBAAiB;AACjB,EAAAA,iBAAA,oBAAiB;AACjB,EAAAA,iBAAA,gBAAa;AACb,EAAAA,iBAAA,sBAAmB;AAVT,SAAAA;AAAA,GAAA;AAkDL,SAAS,yBAAyB,YAAqC;AAC5E,UAAQ,YAAY;AAAA,IAClB,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAKO,SAAS,sBAAsB,YAAoE;AACxG,UAAQ,YAAY;AAAA,IAClB,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;;;ACnGO,IAAM,eAAN,MAAM,sBAAqB,MAAM;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EAEP,YACE,SACA,aAAqB,KACrB,OAAe,kBACf,SACA;AACA,UAAM,OAAO;AACb,SAAK,OAAO,KAAK,YAAY;AAC7B,SAAK,aAAa;AAClB,SAAK,OAAO;AACZ,SAAK,UAAU;AAGf,QAAI,SAAS;AAEX,WAAK,UAAU,QAAQ;AACvB,WAAK,WAAW,QAAQ;AACxB,WAAK,SAAS,QAAQ;AAGtB,WAAK,OAAO,QAAQ;AACpB,WAAK,QAAQ,QAAQ;AAAA,IACvB;AAGA,WAAO,eAAe,MAAM,WAAW,SAAS;AAGhD,QAAI,MAAM,mBAAmB;AAC3B,YAAM,kBAAkB,MAAM,KAAK,WAAW;AAAA,IAChD;AAAA,EACF;AAAA,EAEA,SAAS;AACP,WAAO;AAAA,MACL,MAAM,KAAK;AAAA,MACX,SAAS,KAAK;AAAA,MACd,YAAY,KAAK;AAAA,MACjB,MAAM,KAAK;AAAA,MACX,SAAS,KAAK;AAAA,MACd,SAAS,KAAK;AAAA,MACd,UAAU,KAAK;AAAA,MACf,QAAQ,KAAK;AAAA,MACb,MAAM,KAAK;AAAA,MACX,OAAO,KAAK;AAAA,MACZ,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IACpC;AAAA,EACF;AAAA;AAAA,EAGA,iBAAiB;AACf,WAAO;AAAA,MACL,gBAAgB;AAAA,MAChB,GAAG,KAAK,OAAO;AAAA,IACjB;AAAA,EACF;AAAA;AAAA,EAGA,OAAO,iBAAiB,MAA6B;AACnD,QAAI,CAAC,QAAQ,OAAO,SAAS,YAAY,EAAE,oBAAoB,SAAS,CAAE,KAAqC,gBAAgB;AAC7H,YAAM,IAAI,MAAM,iCAAiC;AAAA,IACnD;AAEA,UAAM,YAAY;AAYlB,UAAM,QAAQ,IAAI;AAAA,MAChB,UAAU;AAAA,MACV,UAAU;AAAA,MACV,UAAU;AAAA,MACV,UAAU;AAAA,IACZ;AAGA,QAAI,UAAU,YAAY,OAAW,OAAM,UAAU,UAAU;AAC/D,QAAI,UAAU,aAAa,OAAW,OAAM,WAAW,UAAU;AACjE,QAAI,UAAU,WAAW,OAAW,OAAM,SAAS,UAAU;AAC7D,QAAI,UAAU,SAAS,OAAW,OAAM,OAAO,UAAU;AACzD,QAAI,UAAU,UAAU,OAAW,OAAM,QAAQ,UAAU;AAE3D,WAAO;AAAA,EACT;AACF;AAEO,IAAM,YAAN,cAAwB,aAAa;AAAA,EAC1C,YAAY,UAAU,yBAAyB,SAAmC;AAChF,UAAM,SAAS,KAAK,cAAc,OAAO;AAAA,EAC3C;AACF;AAGO,IAAM,sBAAN,cAAkC,UAAU;AAAC;AAE7C,IAAM,qBAAN,cAAiC,aAAa;AAAA,EACnD,YAAY,UAAU,oBAAoB,SAAmC;AAC3E,UAAM,SAAS,KAAK,uBAAuB,OAAO;AAAA,EACpD;AACF;AAEO,IAAM,kBAAN,cAA8B,aAAa;AAAA,EACzC;AAAA,EAEP,YAAY,UAAU,qBAAqB,SAAmC;AAC5E,UAAM,SAAS,KAAK,oBAAoB,OAAO;AAC/C,SAAK,QAAQ,SAAS;AAAA,EACxB;AACF;AAEO,IAAM,gBAAN,cAA4B,aAAa;AAAA,EAC9C,YAAY,UAAU,sBAAsB,SAAmC;AAC7E,UAAM,SAAS,KAAK,aAAa,OAAO;AAAA,EAC1C;AACF;AAEO,IAAM,gBAAN,cAA4B,aAAa;AAAA,EAC9C,YAAY,UAAU,qBAAqB,SAAmC;AAC5E,UAAM,SAAS,KAAK,kBAAkB,OAAO;AAAA,EAC/C;AACF;AAEO,IAAM,2BAAN,cAAuC,aAAa;AAAA,EAClD;AAAA,EACA;AAAA,EAEP,YAAY,UAAU,4CAA4C,SAAmC;AACnG,UAAM,SAAS,KAAK,wBAAwB,OAAO;AACnD,SAAK,UAAU,SAAS;AACxB,SAAK,iBAAiB,SAAS;AAAA,EACjC;AACF;AAEO,IAAM,iBAAN,cAA6B,aAAa;AAAA,EACxC;AAAA,EAEP,YAAY,UAAU,uBAAuB,YAAqB,SAAmC;AACnG,UAAM,SAAS,KAAK,oBAAoB,EAAE,GAAG,SAAS,WAAW,CAAC;AAClE,SAAK,aAAa;AAAA,EACpB;AACF;AAEO,IAAM,cAAN,cAA0B,aAAa;AAAA,EAC5C,YAAY,UAAU,yBAAyB,SAAmC;AAChF,UAAM,SAAS,KAAK,gBAAgB,OAAO;AAAA,EAC7C;AACF;AAEO,IAAM,eAAN,cAA2B,aAAa;AAAA,EAC7C,YAAY,UAAU,iBAAiB,SAAmC;AACxE,UAAM,SAAS,GAAG,iBAAiB,OAAO;AAAA,EAC5C;AACF;AAEO,IAAM,eAAN,cAA2B,aAAa;AAAA,EAC7C,YAAY,UAAU,mBAAmB,SAAmC;AAC1E,UAAM,SAAS,KAAK,iBAAiB,OAAO;AAAA,EAC9C;AACF;AAEO,IAAM,sBAAN,cAAkC,aAAa;AAAA,EACpD,YAAY,SAAiB,SAAmC;AAC9D,UAAM,SAAS,KAAK,mBAAmB,OAAO;AAAA,EAChD;AACF;AAEO,IAAM,cAAN,cAA0B,aAAa;AAAA,EAC5C,YAAY,UAAU,4BAA4B,SAAmC;AACnF,UAAM,SAAS,KAAK,gBAAgB,OAAO;AAAA,EAC7C;AACF;AAGO,SAAS,eAAe,OAAuC;AACpE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,YAAY,OAAoC;AAC9D,SAAO,iBAAiB,aAAa,iBAAiB;AACxD;AAEO,SAAS,qBAAqB,OAA6C;AAChF,SAAO,iBAAiB;AAC1B;AAEO,SAAS,kBAAkB,OAA0C;AAC1E,SAAO,iBAAiB;AAC1B;AAEO,SAAS,gBAAgB,OAAwC;AACtE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,gBAAgB,OAAwC;AACtE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,2BAA2B,OAAmD;AAC5F,SAAO,iBAAiB;AAC1B;AAEO,SAAS,iBAAiB,OAAyC;AACxE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,eAAe,OAAuC;AACpE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,cAAc,OAAsC;AAClE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,eAAe,OAAuC;AACpE,SAAO,iBAAiB;AAC1B;AAEO,SAAS,cAAc,OAAuC;AACnE,SAAO,eAAe,KAAK,KACpB,MAAM,eAAe,UACrB,MAAM,cAAc;AAC7B;AAGO,SAAS,yBAAyB,MAAmE;AAC1G,SACE,OAAO,SAAS,YAChB,SAAS,QACT,oBAAoB,QACnB,KAAqC,mBAAmB;AAE7D;AAGO,SAAS,YAAY,OAK1B;AACA,SACE,OAAO,UAAU,YACjB,UAAU,QACV,cAAc,SACd,OAAQ,MAAgC,aAAa;AAEzD;AAGO,SAAS,mBAAmB,OAIjC;AACA,SACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,EAAE,cAAc;AAEpB;AAGO,SAAS,YAAY,OAE1B;AACA,SACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,OAAQ,MAA+B,YAAY;AAEvD;AAGO,SAAS,eAAe,OAAyC;AACtE,MAAI,eAAe,KAAK,GAAG;AACzB,WAAO,MAAM,eAAe;AAAA,EAC9B;AAEA,MAAI,iBAAiB,OAAO;AAC1B,WAAO;AAAA,MACL,SAAS;AAAA,MACT,MAAM,MAAM;AAAA,MACZ,SAAS,MAAM;AAAA,MACf,OAAO,QAAQ,IAAI,aAAa,gBAAgB,MAAM,QAAQ;AAAA,IAChE;AAAA,EACF;AAEA,SAAO;AAAA,IACL,SAAS;AAAA,IACT,SAAS,OAAO,KAAK;AAAA,EACvB;AACF;AAEO,SAAS,iBAAiB,MAAsB;AACrD,MAAI,yBAAyB,IAAI,GAAG;AAClC,WAAO,aAAa,iBAAiB,IAAI;AAAA,EAC3C;AAEA,MAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,aAAa,MAAM;AAClE,UAAM,YAAY;AAMlB,UAAM,QAAQ,IAAI,MAAM,UAAU,WAAW,eAAe;AAC5D,QAAI,UAAU,KAAM,OAAM,OAAO,UAAU;AAC3C,QAAI,UAAU,MAAO,OAAM,QAAQ,UAAU;AAC7C,WAAO;AAAA,EACT;AAEA,SAAO,IAAI,MAAM,eAAe;AAClC;AAGO,SAAS,gBAAgB,OAAwB;AACtD,MAAI,eAAe,KAAK,GAAG;AACzB,WAAO,MAAM;AAAA,EACf;AAEA,MAAI,iBAAiB,OAAO;AAC1B,WAAO,MAAM;AAAA,EACf;AAEA,SAAO;AACT;AAGO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,eAAe,KAAK,GAAG;AACzB,WAAO,MAAM;AAAA,EACf;AAEA,SAAO;AACT;AAMO,SAAS,eAAe,OAAgB,UAAmB,QAAwB;AACxF,QAAM,UAAmC;AAAA,IACvC;AAAA,IACA;AAAA,EACF;AAEA,MAAI,YAAY,KAAK,GAAG;AACtB,UAAM,EAAE,QAAQ,KAAK,IAAI,MAAM;AAC/B,UAAM,YAAY;AAClB,UAAM,cAAc,WAAW,SAAS,WAAW,WAAW,MAAM;AAGpE,UAAM,eAAe,YAAY,SAAS,KAAK,OAAO,YAAY,CAAC,IAAI,QAAQ,MAAM;AACrF,UAAM,kBAAkB,GAAG,WAAW,GAAG,YAAY;AAGrD,YAAQ,UAAU,WAAW,WAAW;AAExC,YAAQ,QAAQ;AAAA,MACd,KAAK;AACH,cAAM,IAAI,gBAAgB,iBAAiB,OAAO;AAAA,MACpD,KAAK;AACH,cAAM,IAAI,UAAU,iBAAiB,OAAO;AAAA,MAC9C,KAAK;AACH,cAAM,IAAI,yBAAyB,iBAAiB,OAAO;AAAA,MAC7D,KAAK;AACH,cAAM,IAAI,mBAAmB,iBAAiB,OAAO;AAAA,MACvD,KAAK;AACH,cAAM,IAAI,cAAc,iBAAiB,OAAO;AAAA,MAClD,KAAK;AACH,cAAM,IAAI,cAAc,iBAAiB,OAAO;AAAA,MAClD,KAAK,KAAK;AACR,cAAM,mBAAmB,MAAM,SAAS,QAAQ,aAAa;AAC7D,cAAM,aAAa,OAAO,qBAAqB,WAAW,SAAS,kBAAkB,EAAE,IAAI;AAC3F,cAAM,IAAI,eAAe,iBAAiB,YAAY,OAAO;AAAA,MAC/D;AAAA,MACA,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AACH,cAAM,IAAI,YAAY,iBAAiB,OAAO;AAAA,MAChD;AACE,cAAM,IAAI,aAAa,iBAAiB,QAAQ,QAAQ,MAAM,IAAI,OAAO;AAAA,IAC7E;AAAA,EACF,WAAW,mBAAmB,KAAK,GAAG;AACpC,UAAM,eAAe,YAAY,SAAS,KAAK,OAAO,YAAY,CAAC,IAAI,QAAQ,MAAM;AACrF,YAAQ,OAAO,MAAM;AAErB,QAAI,MAAM,SAAS,gBAAgB;AACjC,YAAM,IAAI,aAAa,kBAAkB,YAAY,IAAI,OAAO;AAAA,IAClE;AACA,UAAM,IAAI,aAAa,sCAAsC,YAAY,IAAI,OAAO;AAAA,EACtF,WAAW,YAAY,KAAK,GAAG;AAC7B,YAAQ,gBAAgB;AACxB,UAAM,IAAI,aAAa,MAAM,SAAS,KAAK,iBAAiB,OAAO;AAAA,EACrE,OAAO;AACL,YAAQ,gBAAgB;AACxB,UAAM,IAAI,aAAa,iBAAiB,KAAK,iBAAiB,OAAO;AAAA,EACvE;AACF;AAeO,SAAS,wBAAwB,UAA+B,YAAmC;AACxG,QAAM,UAAmC;AAAA,IACvC,MAAM,SAAS,MAAM;AAAA,IACrB,OAAO,SAAS,MAAM;AAAA,EACxB;AAEA,SAAO,IAAI;AAAA,IACT,SAAS,MAAM;AAAA,IACf,cAAc;AAAA,IACd,SAAS,MAAM,QAAQ;AAAA,IACvB;AAAA,EACF;AACF;;;ACrcO,IAAK,aAAL,kBAAKC,gBAAL;AACL,EAAAA,YAAA,SAAM;AACN,EAAAA,YAAA,UAAO;AACP,EAAAA,YAAA,SAAM;AACN,EAAAA,YAAA,YAAS;AACT,EAAAA,YAAA,WAAQ;AACR,EAAAA,YAAA,UAAO;AACP,EAAAA,YAAA,aAAU;AAPA,SAAAA;AAAA,GAAA;AAaL,SAAS,aAAa,QAAsC;AACjE,SAAO,OAAO,OAAO,UAAU,EAAE,SAAS,MAAoB;AAChE;;;ACbO,IAAM,iBAAN,MAAqB;AAAA;AAAA;AAAA;AAAA,EAI1B,aAAa,MACX,UACA,cACY;AAEZ,UAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,QAAI,kBAAkB,OAAO,SAAS,WAAW,KAAK;AACpD,aAAO;AAAA,IACT;AAGA,QAAI,cAAc;AAChB,cAAQ,cAAc;AAAA,QACpB,KAAK;AACH,iBAAO,MAAM,SAAS,KAAK;AAAA,QAC7B,KAAK;AACH,iBAAO,MAAM,SAAS,KAAK;AAAA,QAC7B,KAAK;AACH,iBAAO,MAAM,SAAS,KAAK;AAAA,QAC7B,KAAK;AACH,iBAAO,MAAM,SAAS,YAAY;AAAA,QACpC,KAAK;AACH,cAAI,CAAC,SAAS,MAAM;AAClB,kBAAM,IAAI,MAAM,+BAA+B;AAAA,UACjD;AACA,iBAAO,SAAS;AAAA,QAClB,SAAS;AAEP,gBAAM,cAAqB;AAC3B,gBAAM,IAAI,MAAM,0BAA0B,OAAO,WAAW,CAAC,EAAE;AAAA,QACjE;AAAA,MACF;AAAA,IACF;AAGA,UAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;AAE5D,QAAI,YAAY,SAAS,kBAAkB,GAAG;AAC5C,aAAO,MAAM,SAAS,KAAK;AAAA,IAC7B;AAEA,QAAI,YAAY,SAAS,OAAO,KAAK,YAAY,SAAS,iBAAiB,GAAG;AAC5E,aAAO,MAAM,SAAS,KAAK;AAAA,IAC7B;AAEA,QAAI,YAAY,SAAS,0BAA0B,KAC/C,YAAY,SAAS,QAAQ,KAC7B,YAAY,SAAS,QAAQ,KAC7B,YAAY,SAAS,QAAQ,GAAG;AAClC,aAAO,MAAM,SAAS,KAAK;AAAA,IAC7B;AAGA,WAAO,MAAM,SAAS,KAAK;AAAA,EAC7B;AAAA;AAAA;AAAA;AAAA,EAKA,OAAO,iBAAiB,MAAwC;AAE9D,UAAM,EAAE,cAAc,SAAS,UAAU,GAAG,aAAa,IAAI;AAC7D,WAAO;AAAA,EACT;AACF;;;AClEO,IAAM,eAAe;AAAA,EAC1B,cAAc;AAAA,EACd,eAAe;AAAA,EACf,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,kBAAkB;AAAA,EAClB,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,eAAe;AACjB;AAOO,IAAM,gBAAgB;AAAA,EAC3B,MAAM;AAAA,EACN,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ,aAAa;AACf;AAOO,IAAM,cAAc;AAAA;AAAA,EAEzB,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,YAAY;AAAA;AAAA,EAGZ,aAAa;AAAA,EACb,cAAc;AAAA,EACd,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,mBAAmB;AAAA,EACnB,cAAc;AAAA;AAAA;AAAA,EAGd,uBAAuB;AAAA,EACvB,gBAAgB;AAAA;AAAA,EAChB,aAAa;AAAA,EACb,qBAAqB;AAAA,EACrB,iBAAiB;AACnB;AAOO,IAAM,cAAc;AAAA,EACzB,oBAAoB;AAAA,EACpB,SAAS;AAAA,EACT,kBAAkB;AAAA,EAClB,qBAAqB;AAAA,EACrB,oBAAoB;AAAA,EACpB,gBAAgB;AAClB;AAOO,IAAM,WAAW;AAAA,EACtB,iBAAiB;AAAA;AAAA,EACjB,eAAe;AAAA;AAAA,EACf,cAAc;AAAA;AAAA,EACd,WAAW;AAAA;AACb;AAOO,IAAM,eAAe;AAAA,EAC1B,qBAAqB;AAAA,EACrB,eAAe;AAAA;AAAA,EACf,WAAW;AAAA;AAAA,EACX,gBAAgB;AAClB;;;AC5FO,IAAK,qBAAL,kBAAKC,wBAAL;AACL,EAAAA,oBAAA,kBAAe;AACf,EAAAA,oBAAA,gBAAa;AACb,EAAAA,oBAAA,eAAY;AACZ,EAAAA,oBAAA,mBAAgB;AAChB,EAAAA,oBAAA,kBAAe;AALL,SAAAA;AAAA,GAAA;AAWL,IAAK,kBAAL,kBAAKC,qBAAL;AACL,EAAAA,kCAAA,WAAQ,KAAR;AACA,EAAAA,kCAAA,WAAQ,KAAR;AACA,EAAAA,kCAAA,iBAAc,KAAd;AACA,EAAAA,kCAAA,aAAU,KAAV;AACA,EAAAA,kCAAA,WAAQ,KAAR;AACA,EAAAA,kCAAA,cAAW,KAAX;AACA,EAAAA,kCAAA,UAAO,KAAP;AAPU,SAAAA;AAAA,GAAA;AAaL,IAAK,oBAAL,kBAAKC,uBAAL;AACL,EAAAA,sCAAA,UAAO,KAAP;AACA,EAAAA,sCAAA,gBAAa,KAAb;AACA,EAAAA,sCAAA,sBAAmB,KAAnB;AACA,EAAAA,sCAAA,iBAAc,KAAd;AAJU,SAAAA;AAAA,GAAA;AAUL,IAAM,oBACX,qBACA,2BACA;AAKK,IAAK,sBAAL,kBAAKC,yBAAL;AAIL,EAAAA,qBAAA,UAAO;AAIP,EAAAA,qBAAA,iBAAc;AARJ,SAAAA;AAAA,GAAA;;;AC7CZ,YAAY,aAAa;AAYzB,IAAI;AAKJ,eAAe,0BAAwC;AACrD,MAAI,CAAC,wBAAwB;AAC3B,QAAI;AACF,YAAM,UAAU,MAAM,OAAO,qCAAqC;AAClE,+BAAyB,QAAQ;AACjC,aAAO,QAAQ;AAAA,IACjB,SAAS,OAAO;AACd,cAAQ,KAAK,mDAAmD,KAAK;AACrE,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AA+BO,IAAe,wBAAf,MAAqC;AAAA,EAChC;AAAA,EACS;AAAA,EACT;AAAA,EACF;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EAOnB,YAAY,QAA2B;AACrC,SAAK,SAAS;AAAA,MACZ,GAAG;AAAA,MACH,SAAS,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAAA,IAC3C;AAGA,SAAK,yBAAyB,IAAI,QAAQ,CAAC,SAAS,WAAW;AAC7D,WAAK,yBAAyB;AAC9B,WAAK,wBAAwB;AAAA,IAC/B,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,cAAuB;AACzB,WAAO,KAAK,YAAY,UAAkB,2BAAmB;AAAA,EAC/D;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,QAA4B;AAC9B,QAAI,CAAC,KAAK,YAAY;AACpB;AAAA,IACF;AAEA,YAAQ,KAAK,WAAW,OAAO;AAAA,MAC7B,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF,KAAa,2BAAmB;AAC9B;AAAA,MACF;AACE;AAAA,IACJ;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,gBAAgD;AAC9D,QAAI,KAAK,YAAY;AACnB,aAAO,KAAK;AAAA,IACd;AAEA,UAAM,SAAS,GAAG,KAAK,OAAO,OAAO,GAAG,KAAK,OAAO;AAGpD,UAAM,oBAAoD;AAAA,MACxD,oBAAoB,KAAK,OAAO,SAAS,uBAAuB,MAAM,KAAK,OAAO,KAAK;AAAA,MACvF,WAAW,KAAK,iBAAiB,KAAK,OAAO,SAAS,aAAa,iBAAiB;AAAA,MACpF,SAAS,KAAK,aAAa;AAAA,MAC3B,iBAAiB;AAAA,IACnB;AAGA,UAAM,UAAU,IAAY,6BAAqB,EAC9C,QAAQ,QAAQ,iBAAiB,EACjC,uBAAuB,KAAK,OAAO,SAAS,qBAAqB,CAAC,GAAG,KAAM,KAAO,GAAK,CAAC;AAG3F,QAAI,KAAK,OAAO,SAAS,eAAe;AACtC,cAAQ,kBAAkB,KAAK,OAAO,QAAQ,aAAa;AAAA,IAC7D;AAEA,QAAI,KAAK,OAAO,SAAS,mBAAmB;AAC1C,cAAQ,sBAAsB,KAAK,OAAO,QAAQ,iBAAiB;AAAA,IACrE;AAGA,UAAM,WAAW,KAAK,YAAY,KAAK,OAAO,SAAS,+BAAuC;AAC9F,YAAQ,iBAAiB,QAAQ;AAGjC,UAAM,eAAe,KAAK,OAAO,SAAS;AAC1C,QAAI,kDAAkD;AACpD,UAAI;AACF,cAAM,sBAAsB,MAAM,wBAAwB;AAC1D,YAAI,qBAAqB;AACvB,kBAAQ,gBAAgB,IAAI,oBAAoB,CAAC;AACjD,kBAAQ,KAAK,mDAAmD;AAAA,QAClE;AAAA,MACF,SAAS,OAAO;AACd,gBAAQ,MAAM,8DAA8D,KAAK;AAAA,MAEnF;AAAA,IACF;AAEA,SAAK,aAAa,QAAQ,MAAM;AAGhC,SAAK,WAAW,QAAQ,OAAO,UAAU;AACvC,UAAI,KAAK,gBAAgB;AACvB,cAAM,KAAK,eAAe,KAAK;AAAA,MACjC;AAAA,IACF,CAAC;AAED,SAAK,WAAW,eAAe,OAAO,UAAU;AAC9C,UAAI,KAAK,gBAAgB;AACvB,cAAM,KAAK,eAAe,KAAK;AAAA,MACjC;AAAA,IACF,CAAC;AAED,SAAK,WAAW,cAAc,OAAO,iBAAiB;AACpD,UAAI,KAAK,eAAe;AACtB,cAAM,KAAK,cAAc,YAAY;AAAA,MACvC;AAAA,IACF,CAAC;AAGD,SAAK,qBAAqB,KAAK,UAAU;AAEzC,QAAI;AACF,YAAM,KAAK,WAAW,MAAM;AAE5B,UAAI,KAAK,wBAAwB;AAC/B,aAAK,uBAAuB;AAAA,MAC9B;AAEA,UAAI,KAAK,aAAa;AACpB,cAAM,KAAK,YAAY;AAAA,MACzB;AAAA,IACF,SAAS,OAAO;AACd,UAAI,KAAK,uBAAuB;AAC9B,aAAK,sBAAsB,KAAc;AAAA,MAC3C;AACA,YAAM;AAAA,IACR;AAEA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAUU,iBAAiB,WAAyD;AAClF,QAAI,SAAiB,0BAAkB;AAEvC,QAAI,gCAA0C;AAC5C,gBAAkB,0BAAkB;AAAA,IACtC;AACA,QAAI,sCAAgD;AAClD,gBAAkB,0BAAkB;AAAA,IACtC;AACA,QAAI,iCAA2C;AAC7C,gBAAkB,0BAAkB;AAAA,IACtC;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKU,YAAY,OAA0C;AAC9D,YAAQ,OAAO;AAAA,MACb;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,MAC1B;AACE,eAAe,iBAAS;AAAA,IAC5B;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,eAAuC;AAC7C,UAAM,UAAkC;AAAA,MACtC,cAAc,KAAK,OAAO,aAAa;AAAA,MACvC,GAAG,KAAK,OAAO,SAAS;AAAA,IAC1B;AAGA,QAAI,KAAK,OAAO,KAAK,aAAa,YAAY,KAAK,OAAO,KAAK,mBAAmB;AAChF,aAAO,OAAO,SAAS,KAAK,OAAO,KAAK,iBAAiB;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAa,eAA8B;AACzC,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,OAAiB,eAAuB,MAA6B;AACnF,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI,MAAM,8BAA8B;AAAA,IAChD;AAEA,UAAM,aAAa,MAAM,KAAK,cAAc;AAE5C,QAAI;AACF,aAAO,MAAM,WAAW,OAAU,YAAY,GAAG,IAAI;AAAA,IACvD,SAAS,OAAO;AACd,YAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC1E,YAAM,IAAI,MAAM,4BAA4B,UAAU,KAAK,YAAY,EAAE;AAAA,IAC3E;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,KAAK,eAAuB,MAAgC;AAC1E,QAAI,KAAK,UAAU;AACjB,YAAM,IAAI,MAAM,8BAA8B;AAAA,IAChD;AAEA,UAAM,aAAa,MAAM,KAAK,cAAc;AAE5C,QAAI;AACF,YAAM,WAAW,KAAK,YAAY,GAAG,IAAI;AAAA,IAC3C,SAAS,OAAO;AACd,YAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC1E,YAAM,IAAI,MAAM,0BAA0B,UAAU,KAAK,YAAY,EAAE;AAAA,IACzE;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAa,aAA4B;AACvC,QAAI,KAAK,cAAc,KAAK,WAAW,UAAkB,2BAAmB,cAAc;AACxF,YAAM,KAAK,WAAW,KAAK;AAC3B,WAAK,aAAa;AAGlB,WAAK,yBAAyB,IAAI,QAAQ,CAAC,SAAS,WAAW;AAC7D,aAAK,yBAAyB;AAC9B,aAAK,wBAAwB;AAAA,MAC/B,CAAC;AAAA,IACH;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAa,UAAyB;AACpC,SAAK,WAAW;AAChB,UAAM,KAAK,WAAW;AACtB,SAAK,yBAAyB;AAC9B,SAAK,wBAAwB;AAAA,EAC/B;AACF;;;AChSO,IAAM,YAAN,cAAwB,MAAM;AAAA,EAC5B;AAAA,EACA;AAAA,EAKA;AAAA,EACA;AAAA,EAMP,YAAY,SAAiB,MAAe;AAC1C,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AAAA,EACd;AACF;;;AC5EO,IAAK,oBAAL,kBAAKC,uBAAL;AAEL,EAAAA,mBAAA,iBAAc;AAEd,EAAAA,mBAAA,yBAAsB;AAEtB,EAAAA,mBAAA,mBAAgB;AANN,SAAAA;AAAA,GAAA;AAkEL,SAAS,oBACd,UACA,SACQ;AACR,UAAQ,SAAS,MAAM;AAAA,IACrB,KAAK;AACH,aAAO,SAAS;AAAA,IAElB,KAAK,iDAAuC;AAC1C,YAAM,QAAQ,KAAK;AAAA,QACjB,SAAS,iBAAiB,KAAK,IAAI,SAAS,QAAQ,UAAU,CAAC;AAAA,QAC/D,SAAS;AAAA,MACX;AACA,UAAI,SAAS,QAAQ;AAEnB,eAAO,QAAQ,KAAK,OAAO,IAAI;AAAA,MACjC;AACA,aAAO;AAAA,IACT;AAAA,IAEA,KAAK,qCAAiC;AAEpC,YAAM,QAAQ,KAAK,IAAI,UAAU,GAAG,SAAS,OAAO,SAAS,CAAC;AAC9D,aAAO,SAAS,OAAO,KAAK;AAAA,IAC9B;AAAA,EACF;AACF;AAOO,SAAS,cAAc,UAAiC;AAC7D,UAAQ,SAAS,MAAM;AAAA,IACrB,KAAK;AAAA,IACL,KAAK;AACH,aAAO,SAAS;AAAA,IAClB,KAAK;AACH,aAAO,SAAS,OAAO;AAAA,EAC3B;AACF;AAQO,SAAS,wBACd,UACA,OACS;AACT,MAAI,SAAS,gBAAgB;AAC3B,WAAO,SAAS,eAAe,KAAK;AAAA,EACtC;AAEA,SAAO;AACT;AAKO,IAAM,2BAA2B;AAAA;AAAA,EAEtC,SAAS;AAAA,IACP,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR,QAAQ;AAAA,EACV;AAAA;AAAA,EAGA,OAAO;AAAA,IACL,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,SAAS;AAAA,EACX;AACF;;;ACjHO,IAAe,gBAAf,MAA6B;AAAA;AAAA,EAEf;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EAEnB,YAAY,QAA6B;AACvC,SAAK,UAAU,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAC/C,SAAK,UAAU,OAAO,WAAW;AACjC,SAAK,iBAAiB,OAAO,kBAAkB,CAAC;AAChD,SAAK,gBAAgB,OAAO,iBAAiB,KAAK,wBAAwB;AAC1E,SAAK,QAAQ,OAAO,SAAS;AAE7B,SAAK,UAAU,OAAO;AACtB,SAAK,YAAY,OAAO;AACxB,SAAK,aAAa,OAAO;AACzB,SAAK,SAAS,OAAO;AACrB,SAAK,QAAQ,OAAO;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiCA,MAAgB,oBAAoB,UAAoC;AACtE,QAAI;AACJ,QAAI;AACF,YAAM,cAAc,SAAS,QAAQ,IAAI,cAAc;AACvD,UAAI,aAAa,SAAS,kBAAkB,GAAG;AAC7C,oBAAY,MAAM,SAAS,KAAK;AAAA,MAClC;AAAA,IACF,QAAQ;AACN,kBAAY,CAAC;AAAA,IACf;AAGA,WAAO,IAAI;AAAA,MACT,QAAQ,SAAS,MAAM,KAAK,SAAS,UAAU;AAAA,MAC/C,SAAS;AAAA,MACT,QAAQ,SAAS,MAAM;AAAA,MACvB,EAAE,MAAM,UAAU;AAAA,IACpB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUU,YAAY,OAAgB,SAA0B;AAC9D,UAAM,aAAa,cAAc,KAAK,aAAa;AACnD,QAAI,UAAU,WAAY,QAAO;AAGjC,QAAI,KAAK,cAAc,gBAAgB;AACrC,aAAO,KAAK,cAAc,eAAe,KAAK;AAAA,IAChD;AAGA,QAAI,iBAAiB,cAAc;AAEjC,aAAO,MAAM,eAAe,OAAO,MAAM,cAAc;AAAA,IACzD;AAEA,QAAI,iBAAiB,OAAO;AAE1B,aACE,MAAM,SAAS,gBACf,MAAM,QAAQ,SAAS,SAAS,KAChC,MAAM,QAAQ,SAAS,OAAO;AAAA,IAElC;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWU,cAAc,QAAiB,SAAyB;AAChE,WAAO,oBAAoB,KAAK,eAAe,OAAO;AAAA,EACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAgB,QACd,KACA,UAAyE,CAAC,GACtD;AACpB,UAAM,UAAU,KAAK,SAAS,GAAG;AACjC,UAAM,aAAa,IAAI,gBAAgB;AAEvC,UAAM,YAAY,QAAQ,WAAW,KAAK;AAC1C,UAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAEhE,QAAI;AACF,YAAM,cAAiC;AAAA,QACrC,QAAQ,QAAQ;AAAA,QAChB,KAAK;AAAA,QACL,SAAS,KAAK,aAAa,QAAQ,OAAO;AAAA,QAC1C,MAAM,QAAQ;AAAA,MAChB;AAGA,UAAI,KAAK,WAAW;AAClB,cAAM,KAAK,UAAU,WAAW;AAAA,MAClC;AAEA,WAAK,IAAI,SAAS,gBAAgB,YAAY,MAAM,IAAI,YAAY,GAAG,EAAE;AAEzE,YAAM,WAAW,MAAM,KAAK;AAAA,QAC1B;AAAA,QACA;AAAA,UACE,QAAQ,YAAY;AAAA,UACpB,SAAS,YAAY;AAAA,UACrB,MAAM,QAAQ,OAAO,KAAK,UAAU,QAAQ,IAAI,IAAI;AAAA,UACpD,QAAQ,QAAQ,UAAU,WAAW;AAAA,UACrC,cAAc,QAAQ;AAAA,UACtB,SAAS;AAAA,QACX;AAAA,MACF;AAEA,aAAO;AAAA,IACT,UAAE;AACA,mBAAa,SAAS;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,IACd,KACA,SACoB;AACpB,WAAO,KAAK,QAAmB,KAAK,EAAE,GAAG,SAAS,wBAAuB,CAAC;AAAA,EAC5E;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,KACd,KACA,MACA,SACoB;AACpB,WAAO,KAAK,QAA6B,KAAK;AAAA,MAC5C,GAAG;AAAA,MACH;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,IACd,KACA,MACA,SACoB;AACpB,WAAO,KAAK,QAA6B,KAAK;AAAA,MAC5C,GAAG;AAAA,MACH;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,MACd,KACA,MACA,SACoB;AACpB,WAAO,KAAK,QAA6B,KAAK;AAAA,MAC5C,GAAG;AAAA,MACH;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAgB,OACd,KACA,SACoB;AACpB,WAAO,KAAK,QAAmB,KAAK,EAAE,GAAG,SAAS,8BAA0B,CAAC;AAAA,EAC/E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAc,iBACZ,KACA,MACA,UAAkB,GACE;AACpB,QAAI;AACF,YAAM,WAAW,MAAM,MAAM,KAAK,eAAe,iBAAiB,IAAI,CAAC;AAEvE,WAAK,IAAI,SAAS,iBAAiB,SAAS,MAAM,IAAI,SAAS,UAAU,EAAE;AAG3E,YAAM,UAAkC,CAAC;AACzC,eAAS,QAAQ,QAAQ,CAAC,OAAO,QAAQ;AACvC,gBAAQ,GAAG,IAAI;AAAA,MACjB,CAAC;AAGD,UAAI,KAAK,YAAY;AACnB,cAAM,eAA6B;AAAA,UACjC,QAAQ,SAAS;AAAA,UACjB,YAAY,SAAS;AAAA,UACrB;AAAA,UACA,MAAM;AAAA,UACN,QAAQ;AAAA,YACN;AAAA,YACA,QAAS,KAAK;AAAA,YACd,SAAU,KAAK,WAAsC,CAAC;AAAA,UACxD;AAAA,QACF;AACA,cAAM,KAAK,WAAW,YAAY;AAAA,MACpC;AAEA,UAAI,CAAC,SAAS,IAAI;AAChB,cAAM,QAAQ,MAAM,KAAK,oBAAoB,QAAQ;AACrD,cAAM;AAAA,MACR;AAGA,YAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;AAC3D,UAAI,kBAAkB,OAAO,SAAS,WAAW,KAAK;AACpD,eAAO;AAAA,MACT;AAEA,aAAO,MAAM,eAAe,MAAiB,UAAU,KAAK,YAAY;AAAA,IAC1E,SAAS,OAAO;AACd,UAAI,KAAK,YAAY,OAAO,OAAO,GAAG;AACpC,cAAM,QAAQ,KAAK,cAAc,OAAO,OAAO;AAC/C,aAAK,IAAI,SAAS,6BAA6B,UAAU,CAAC,WAAW,KAAK,IAAI;AAE9E,cAAM,KAAK,MAAM,KAAK;AACtB,eAAO,KAAK,iBAA4B,KAAK,MAAM,UAAU,CAAC;AAAA,MAChE;AAGA,UAAI,KAAK,WAAW,iBAAiB,OAAO;AAC1C,aAAK,QAAQ,KAAK;AAAA,MACpB;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,SAAS,MAAsB;AAErC,QAAI,KAAK,WAAW,SAAS,KAAK,KAAK,WAAW,UAAU,GAAG;AAC7D,aAAO;AAAA,IACT;AAGA,UAAM,YAAY,KAAK,WAAW,GAAG,IAAI,OAAO,IAAI,IAAI;AACxD,WAAO,GAAG,KAAK,OAAO,GAAG,SAAS;AAAA,EACpC;AAAA;AAAA;AAAA;AAAA,EAKQ,aAAa,mBAAoE;AACvF,WAAO;AAAA,MACL,CAAC,aAAa,YAAY,GAAG,cAAc;AAAA,MAC3C,GAAG,KAAK,eAAe;AAAA,MACvB,GAAG,KAAK;AAAA,MACR,GAAG;AAAA,IACL;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKU,IACR,OACA,YACG,MACG;AACN,QAAI,KAAK,SAAS,KAAK,GAAG;AACxB,WAAK,OAAO,KAAK,EAAE,SAAS,GAAG,IAAI;AAAA,IACrC,WAAW,KAAK,SAAS,UAAU,SAAS;AAC1C,cAAQ,KAAK,SAAS,OAAO,IAAI,GAAG,IAAI;AAAA,IAC1C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKQ,MAAM,IAA2B;AACvC,WAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,EACzD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAgB,aAAgB,KAAgC;AAC9D,QAAI,CAAC,KAAK,MAAO,QAAO;AAExB,QAAI;AACF,YAAM,SAAS,MAAM,KAAK,MAAM,IAAO,GAAG;AAC1C,UAAI,QAAQ;AACV,aAAK,IAAI,SAAS,sBAAsB,GAAG,EAAE;AAC7C,eAAO;AAAA,MACT;AAAA,IACF,SAAS,OAAO;AACd,WAAK,IAAI,SAAS,oBAAoB,KAAK;AAAA,IAC7C;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAgB,SAAS,KAAa,OAAgB,KAA6B;AACjF,QAAI,CAAC,KAAK,MAAO;AAEjB,QAAI;AACF,YAAM,KAAK,MAAM,IAAI,KAAK,OAAO,GAAG;AACpC,WAAK,IAAI,SAAS,sBAAsB,GAAG,EAAE;AAAA,IAC/C,SAAS,OAAO;AACd,WAAK,IAAI,SAAS,oBAAoB,KAAK;AAAA,IAC7C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAgB,UACd,UACA,IACA,KACY;AACZ,UAAM,SAAS,MAAM,KAAK,aAAgB,QAAQ;AAClD,QAAI,WAAW,MAAM;AACnB,aAAO;AAAA,IACT;AAEA,UAAM,SAAS,MAAM,GAAG;AACxB,UAAM,KAAK,SAAS,UAAU,QAAQ,GAAG;AAEzC,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKU,YACR,aACG,aACK;AACR,UAAM,QAAQ,YACX,OAAO,CAAC,OAAO,OAAO,MAAS,EAC/B,IAAI,CAAC,OAAQ,OAAO,OAAO,WAAW,KAAK,UAAU,EAAE,IAAI,OAAO,EAAE,CAAE;AACzE,WAAO,GAAG,QAAQ,IAAI,MAAM,KAAK,GAAG,CAAC;AAAA,EACvC;AACF;;;ACndO,IAAK,eAAL,kBAAKC,kBAAL;AAEL,EAAAA,cAAA,YAAS;AAET,EAAAA,cAAA,UAAO;AAEP,EAAAA,cAAA,eAAY;AANF,SAAAA;AAAA,GAAA;;;ACAL,IAAM,0BAAN,cAAsC,aAAa;AAAA;AAAA,EAExC;AAAA;AAAA,EAGA;AAAA;AAAA,EAGA;AAAA,EAEhB,YACE,SACA,OACA,mBACA;AACA,UAAM,SAAS,KAAK,wBAAwB;AAAA,MAC1C,cAAc,MAAM;AAAA,MACpB;AAAA,MACA,qBAAqB,MAAM;AAAA,MAC3B,eAAe,MAAM;AAAA,IACvB,CAAC;AAED,SAAK,eAAe,MAAM;AAC1B,SAAK,oBAAoB;AACzB,SAAK,QAAQ;AAAA,EACf;AACF;AAKO,SAAS,0BAA0B,OAAkD;AAC1F,SAAO,iBAAiB;AAC1B;;;AC3BA,IAAM,iBAA+E;AAAA,EACnF,kBAAkB;AAAA,EAClB,iBAAiB;AAAA;AAAA,EACjB,gBAAgB;AAAA;AAAA,EAChB,kBAAkB;AAAA,EAClB,eAAe;AACjB;AAeO,IAAM,iBAAN,MAAqB;AAAA,EACT;AAAA,EAEA;AAAA;AAAA,EAGT;AAAA,EACA,WAA4B,CAAC;AAAA,EAC7B,oBAA4B;AAAA;AAAA,EAG5B,gBAAwB;AAAA,EACxB,iBAAyB;AAAA,EACzB,mBAA2B;AAAA,EAC3B,kBAAiC;AAAA,EACjC,gBAA+B;AAAA,EAC/B,gBAA+B;AAAA,EAEvC,YACE,SAA+B,CAAC,GAChC,YAAqC,CAAC,GACtC;AACA,SAAK,SAAS;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,IACL;AACA,SAAK,YAAY;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAyB;AAEvB,QAAI,KAAK,+BAA+B,KAAK,oBAAoB,MAAM;AACrE,YAAM,UAAU,KAAK,IAAI,IAAI,KAAK;AAClC,UAAI,WAAW,KAAK,OAAO,gBAAgB;AACzC,aAAK,wCAAmC;AAAA,MAC1C;AAAA,IACF;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAKA,WAAgC;AAC9B,UAAM,eAAe,KAAK,SAAS;AACnC,WAAO;AAAA,MACL,OAAO;AAAA,MACP,qBAAqB,KAAK,+BAA+B;AAAA,MACzD,eAAe,KAAK;AAAA,MACpB,gBAAgB,KAAK;AAAA,MACrB,iBAAiB,KAAK;AAAA,MACtB,mBAAmB,KAAK,2BAA2B;AAAA,MACnD,eAAe,KAAK;AAAA,MACpB,eAAe,KAAK;AAAA,MACpB,kBAAkB,KAAK;AAAA,IACzB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,aAAsB;AACpB,UAAM,QAAQ,KAAK,SAAS;AAC5B,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAkB;AAChB,UAAM,QAAQ,KAAK,SAAS;AAC5B,QAAI,6BAA6B;AAC/B,WAAK;AACL,YAAM,QAAQ,KAAK,SAAS;AAC5B,WAAK,UAAU,aAAa,KAAK;AAEjC,YAAM,IAAI;AAAA,QACR,yCAAyC,KAAK,MAAM,MAAM,qBAAqB,KAAK,GAAI,CAAC;AAAA,QACzF;AAAA,QACA,MAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,gBAAsB;AACpB,SAAK;AACL,SAAK,gBAAgB,KAAK,IAAI;AAE9B,UAAM,eAAe,KAAK,SAAS;AAEnC,QAAI,8CAAyC;AAC3C,WAAK;AACL,WAAK,IAAI,SAAS,qBAAqB,KAAK,iBAAiB,IAAI,KAAK,OAAO,gBAAgB,EAAE;AAE/F,UAAI,KAAK,qBAAqB,KAAK,OAAO,kBAAkB;AAC1D,aAAK,kCAAgC;AAAA,MACvC;AAAA,IACF,WAAW,wCAAsC;AAE/C,WAAK,WAAW,CAAC;AAAA,IACnB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,cAAc,OAAsB;AAElC,QAAI,KAAK,OAAO,wBAAwB,CAAC,KAAK,OAAO,qBAAqB,KAAK,GAAG;AAChF,WAAK,IAAI,SAAS,+CAA+C;AACjE;AAAA,IACF;AAEA,UAAM,MAAM,KAAK,IAAI;AACrB,SAAK;AACL,SAAK,gBAAgB;AAErB,UAAM,eAAe,KAAK,SAAS;AAEnC,QAAI,8CAAyC;AAE3C,WAAK,IAAI,QAAQ,+CAA+C;AAChE,WAAK,gCAAgC,KAAK;AAC1C;AAAA,IACF;AAEA,QAAI,wCAAsC;AAExC,WAAK,SAAS,KAAK,EAAE,WAAW,KAAK,MAAM,CAAC;AAG5C,WAAK,iBAAiB;AAGtB,YAAM,sBAAsB,KAAK,+BAA+B;AAChE,WAAK,IAAI,SAAS,yBAAyB,mBAAmB,IAAI,KAAK,OAAO,gBAAgB,EAAE;AAEhG,UAAI,uBAAuB,KAAK,OAAO,kBAAkB;AACvD,aAAK,gCAAgC,KAAK;AAAA,MAC5C;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAc;AACZ,SAAK,IAAI,QAAQ,wBAAwB;AACzC,SAAK,kCAAgC;AACrC,SAAK,WAAW,CAAC;AACjB,SAAK,gBAAgB;AACrB,SAAK,iBAAiB;AACtB,SAAK,mBAAmB;AAAA,EAC1B;AAAA;AAAA,EAIQ,aAAa,UAAwB,cAA8B;AACzE,UAAM,WAAW,KAAK;AACtB,QAAI,aAAa,SAAU;AAE3B,SAAK,QAAQ;AACb,UAAM,QAAQ,KAAK,SAAS;AAE5B,SAAK,IAAI,QAAQ,yBAAyB,QAAQ,OAAO,QAAQ,EAAE;AAEnE,YAAQ,UAAU;AAAA,MAChB;AACE,aAAK,kBAAkB,KAAK,IAAI;AAChC,aAAK,oBAAoB;AACzB,aAAK,UAAU,SAAS,OAAO,YAAY;AAC3C;AAAA,MAEF;AACE,aAAK,oBAAoB;AACzB,aAAK,UAAU,aAAa,KAAK;AACjC;AAAA,MAEF;AACE,aAAK,kBAAkB;AACvB,aAAK,WAAW,CAAC;AACjB,aAAK,oBAAoB;AACzB,aAAK,UAAU,UAAU,KAAK;AAC9B;AAAA,IACJ;AAEA,SAAK,UAAU,gBAAgB,UAAU,UAAU,KAAK;AAAA,EAC1D;AAAA,EAEQ,mBAAyB;AAC/B,UAAM,SAAS,KAAK,IAAI,IAAI,KAAK,OAAO;AACxC,SAAK,WAAW,KAAK,SAAS,OAAO,OAAK,EAAE,aAAa,MAAM;AAAA,EACjE;AAAA,EAEQ,iCAAyC;AAC/C,SAAK,iBAAiB;AACtB,WAAO,KAAK,SAAS;AAAA,EACvB;AAAA,EAEQ,6BAA4C;AAClD,QAAI,KAAK,+BAA+B,KAAK,oBAAoB,MAAM;AACrE,aAAO;AAAA,IACT;AAEA,UAAM,UAAU,KAAK,IAAI,IAAI,KAAK;AAClC,UAAM,YAAY,KAAK,OAAO,iBAAiB;AAC/C,WAAO,YAAY,IAAI,YAAY;AAAA,EACrC;AAAA,EAEQ,IAAI,QAA6C,SAAuB;AAC9E,QAAI,KAAK,OAAO,eAAe;AAC7B,cAAQ,KAAK,oBAAoB,OAAO,EAAE;AAAA,IAC5C;AAAA,EACF;AACF;","names":["ModelCapability","HttpMethod","HubConnectionState","SignalRLogLevel","HttpTransportType","SignalRProtocolType","RetryStrategyType","CircuitState"]} \ No newline at end of file diff --git a/SDKs/Node/Common/package.json b/SDKs/Node/Common/package.json index 16f016e13..25c9ca3b9 100755 --- a/SDKs/Node/Common/package.json +++ b/SDKs/Node/Common/package.json @@ -21,12 +21,12 @@ "types", "sdk" ], - "author": "KNN Labs", + "author": "Nick Nassiri", "license": "MIT", "devDependencies": { - "@types/node": "^24.0.15", - "tsup": "^8.1.0", - "typescript": "^5.8.3" + "@types/node": "^25.0.3", + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "peerDependencies": { "typescript": ">=4.5.0" @@ -39,7 +39,7 @@ } }, "dependencies": { - "@microsoft/signalr": "^8.0.7", - "@microsoft/signalr-protocol-msgpack": "^8.0.7" + "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr-protocol-msgpack": "^10.0.0" } } diff --git a/SDKs/Node/Common/src/errors/error-messages.ts b/SDKs/Node/Common/src/errors/error-messages.ts new file mode 100644 index 000000000..3c3fd647e --- /dev/null +++ b/SDKs/Node/Common/src/errors/error-messages.ts @@ -0,0 +1,260 @@ +/** + * User-friendly error message mapping for OpenAI-compatible error responses. + * + * Maps HTTP status codes to titles, messages, suggestions, severity, + * and recoverability flags. Works with the ConduitError hierarchy + * from this same package. + */ + +export interface OpenAIError { + message: string; + type: string; + code?: string; + param?: string; +} + +export interface OpenAIErrorResponse { + error: OpenAIError; +} + +export interface ErrorMessageConfig { + getTitle: () => string; + getMessage: (error?: OpenAIError) => string; + getSuggestions: (error?: OpenAIError) => string[]; + isRecoverable: boolean; +} + +/** + * Extract a retry-after value (in seconds) from an error message. + */ +export function extractRetryAfter(error?: OpenAIError): number | undefined { + if (error?.message) { + const match = error.message.match(/\b(\d+)\s*seconds?\b/i); + if (match) { + return parseInt(match[1], 10); + } + } + return undefined; +} + +/** + * Maps HTTP status codes to user-friendly error configurations. + */ +export const ERROR_MESSAGES: Record = { + [400]: { + getTitle: () => 'Invalid Request', + getMessage: (error) => { + if (error?.code === 'missing_parameter' && error.param) { + return `Required parameter '${error.param}' is missing`; + } + if (error?.code === 'invalid_parameter' && error.param) { + return `Invalid value for parameter '${error.param}'`; + } + return error?.message ?? 'Your request contains invalid parameters. Please check your input and try again.'; + }, + getSuggestions: (error) => { + const suggestions = []; + if (error?.param) { + suggestions.push(`Check the value of '${error.param}'`); + } + suggestions.push('Review the API documentation for parameter requirements'); + suggestions.push('Ensure all required fields are provided'); + return suggestions; + }, + isRecoverable: false, + }, + + [401]: { + getTitle: () => 'Authentication Failed', + getMessage: (error) => + error?.message ?? 'Authentication failed. Please check your API key.', + getSuggestions: () => [ + 'Verify your API key is correct', + 'Check that your API key has not expired', + 'Ensure your API key has the necessary permissions', + ], + isRecoverable: false, + }, + + [402]: { + getTitle: () => 'Insufficient Balance', + getMessage: (error) => + error?.message ?? 'Your account balance is insufficient to complete this request.', + getSuggestions: () => [ + 'Add credits to your account', + 'Check your usage limits in account settings', + 'Contact billing support if you believe this is an error', + ], + isRecoverable: false, + }, + + [403]: { + getTitle: () => 'Access Denied', + getMessage: (error) => + error?.message ?? 'You do not have permission to access this resource.', + getSuggestions: () => [ + 'Check your account permissions', + 'Contact your administrator for access', + 'Verify you are using the correct API endpoint', + ], + isRecoverable: false, + }, + + [404]: { + getTitle: () => 'Not Found', + getMessage: (error) => { + if (error?.code === 'model_not_found' && error.param) { + return `The model "${error.param}" is not available. Please select a different model.`; + } + return error?.message ?? 'The requested resource was not found.'; + }, + getSuggestions: (error) => { + if (error?.code === 'model_not_found') { + return [ + 'Check available models in the model selector', + 'Contact support if you need access to this model', + 'Try using an alternative model with similar capabilities', + ]; + } + return [ + 'Verify the resource exists', + 'Check for typos in the resource identifier', + 'Ensure you have the correct permissions', + ]; + }, + isRecoverable: false, + }, + + [408]: { + getTitle: () => 'Request Timeout', + getMessage: (error) => + error?.message ?? 'Your request took too long to process and timed out.', + getSuggestions: () => [ + 'Try with a shorter prompt or simpler request', + 'Break large requests into smaller chunks', + 'Check your network connection', + 'Try again during off-peak hours', + ], + isRecoverable: true, + }, + + [413]: { + getTitle: () => 'Request Too Large', + getMessage: (error) => + error?.message ?? 'Your request exceeds the maximum allowed size.', + getSuggestions: () => [ + 'Reduce the size of your input', + 'Split large requests into smaller chunks', + 'Remove unnecessary data from your request', + 'Consider using a streaming approach for large data', + ], + isRecoverable: false, + }, + + [429]: { + getTitle: () => 'Rate Limit Exceeded', + getMessage: (error) => { + const retryAfter = extractRetryAfter(error); + if (retryAfter) { + return `Rate limit exceeded. Please wait ${retryAfter} seconds before trying again.`; + } + return error?.message ?? 'You have exceeded the rate limit. Please slow down your requests.'; + }, + getSuggestions: (error) => { + const suggestions = []; + const retryAfter = extractRetryAfter(error); + if (retryAfter) { + suggestions.push(`Wait ${retryAfter} seconds before retrying`); + } + suggestions.push('Consider upgrading your plan for higher limits'); + suggestions.push('Implement request batching to reduce API calls'); + suggestions.push('Add delays between consecutive requests'); + return suggestions; + }, + isRecoverable: true, + }, + + [500]: { + getTitle: () => 'Server Error', + getMessage: (error) => + error?.message ?? 'An unexpected server error occurred. Our team has been notified.', + getSuggestions: () => [ + 'Try again in a few moments', + 'Check the service status page', + 'Contact support if the issue persists', + ], + isRecoverable: true, + }, + + [502]: { + getTitle: () => 'Bad Gateway', + getMessage: (error) => + error?.message ?? 'The server received an invalid response from an upstream server.', + getSuggestions: () => [ + 'Wait a few moments and try again', + 'Check the service status page', + 'Try a different endpoint if available', + ], + isRecoverable: true, + }, + + [503]: { + getTitle: () => 'Service Unavailable', + getMessage: (error) => + error?.message ?? 'The service is temporarily unavailable. Please try again later.', + getSuggestions: () => [ + 'Wait a few minutes before retrying', + 'Check the service status page for maintenance windows', + 'Try during off-peak hours', + 'Consider implementing automatic retry logic', + ], + isRecoverable: true, + }, + + [504]: { + getTitle: () => 'Gateway Timeout', + getMessage: (error) => + error?.message ?? 'The server did not receive a timely response from an upstream server.', + getSuggestions: () => [ + 'Try again with a simpler request', + 'Check your network connectivity', + 'Wait a few moments before retrying', + ], + isRecoverable: true, + }, +}; + +/** + * Get the default error configuration for unknown status codes. + */ +export function getDefaultErrorConfig(): ErrorMessageConfig { + return { + getTitle: () => 'Error', + getMessage: (error) => error?.message ?? 'An unexpected error occurred.', + getSuggestions: () => [ + 'Try again in a few moments', + 'Contact support if the issue persists', + ], + isRecoverable: false, + }; +} + +/** + * Get error configuration for a specific HTTP status code. + */ +export function getErrorConfig(statusCode: number): ErrorMessageConfig { + return ERROR_MESSAGES[statusCode] ?? getDefaultErrorConfig(); +} + +/** + * Get the severity level for an error based on status code. + */ +export function getErrorSeverity(statusCode: number): 'error' | 'warning' | 'info' { + if (statusCode >= 500) { + return 'error'; + } + if (statusCode === 429 || statusCode === 408) { + return 'warning'; + } + return 'info'; +} diff --git a/SDKs/Node/Common/src/errors/index.ts b/SDKs/Node/Common/src/errors/index.ts index f08f0bf9f..b494b0eb7 100755 --- a/SDKs/Node/Common/src/errors/index.ts +++ b/SDKs/Node/Common/src/errors/index.ts @@ -360,10 +360,24 @@ export function getErrorStatusCode(error: unknown): number { if (isConduitError(error)) { return error.statusCode; } - + return 500; } +// User-friendly error message mapping +export { + ERROR_MESSAGES, + getDefaultErrorConfig, + getErrorConfig, + getErrorSeverity, + extractRetryAfter +} from './error-messages'; +export type { + OpenAIError, + OpenAIErrorResponse, + ErrorMessageConfig +} from './error-messages'; + /** * Handle API errors and convert them to appropriate ConduitError types * This function is primarily used by the Admin SDK @@ -376,15 +390,31 @@ export function handleApiError(error: unknown, endpoint?: string, method?: strin if (isHttpError(error)) { const { status, data } = error.response; - const errorData = data as { error?: string; message?: string; details?: unknown } | null; - const baseMessage = errorData?.error || errorData?.message || error.message; - + // Support both standard error format and ASP.NET Core ProblemDetails format + const errorData = data as { + error?: string; + message?: string; + details?: unknown; + // ProblemDetails fields + title?: string; + detail?: string; + traceId?: string; + errorType?: string; + extensions?: Record; + } | null; + + // Extract message from various possible fields, preferring detail for ProblemDetails + const baseMessage = errorData?.detail || errorData?.error || errorData?.message || errorData?.title || error.message; + // Enhanced error messages with endpoint information const endpointInfo = endpoint && method ? ` (${method.toUpperCase()} ${endpoint})` : ''; const enhancedMessage = `${baseMessage}${endpointInfo}`; - - // Add details to context + + // Add details to context, including ProblemDetails extensions context.details = errorData?.details || data; + if (errorData?.traceId) context.traceId = errorData.traceId; + if (errorData?.errorType) context.errorType = errorData.errorType; + if (errorData?.extensions) context.extensions = errorData.extensions; switch (status) { case 400: diff --git a/SDKs/Node/Common/src/formatting/formatters.ts b/SDKs/Node/Common/src/formatting/formatters.ts new file mode 100644 index 000000000..104473bc2 --- /dev/null +++ b/SDKs/Node/Common/src/formatting/formatters.ts @@ -0,0 +1,301 @@ +/** + * Comprehensive formatting utilities for consistent data presentation + * across Conduit SDK consumers. + */ + +import type { DateFormatOptions, CurrencyFormatOptions, NumberFormatOptions } from './types'; + +/** + * Centralized formatting utilities with comprehensive options + */ +export const formatters = { + /** + * Format dates with intelligent defaults and extensive customization + */ + date: ( + dateInput: string | Date | null | undefined, + options: DateFormatOptions = {} + ): string => { + if (!dateInput) return 'Never'; + + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; + + if (isNaN(date.getTime())) { + return 'Invalid Date'; + } + + const { + locale = 'en-US', + includeTime = true, + includeSeconds = false, + relativeDays = 7, + ...intlOptions + } = options; + + // Handle relative dates for recent timestamps + if (relativeDays > 0) { + const now = new Date(); + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return `Today at ${formatters.time(date, { locale })}`; + if (diffDays === 1) return `Yesterday at ${formatters.time(date, { locale })}`; + if (diffDays < relativeDays) return `${diffDays} days ago`; + } + + const defaultOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + ...(includeTime && { + hour: '2-digit', + minute: '2-digit', + ...(includeSeconds && { second: '2-digit' }) + }), + ...intlOptions + }; + + return date.toLocaleDateString(locale, defaultOptions); + }, + + /** + * Format time only + */ + time: ( + dateInput: string | Date | null | undefined, + options: { locale?: string; includeSeconds?: boolean } = {} + ): string => { + if (!dateInput) return '--:--'; + + const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; + if (isNaN(date.getTime())) return '--:--'; + + const { locale = 'en-US', includeSeconds = false } = options; + + return date.toLocaleTimeString(locale, { + hour: '2-digit', + minute: '2-digit', + ...(includeSeconds && { second: '2-digit' }) + }); + }, + + /** + * Format dates without time component + */ + dateOnly: ( + dateInput: string | Date | null | undefined, + options: { locale?: string } = {} + ): string => { + return formatters.date(dateInput, { + ...options, + includeTime: false + }); + }, + + /** + * Format currency with intelligent defaults and customization + */ + currency: ( + amount: number | null | undefined, + options: CurrencyFormatOptions = {} + ): string => { + if (amount === null || amount === undefined || isNaN(amount)) { + return '$0.00'; + } + + const { + locale = 'en-US', + currency = 'USD', + compact = false, + precision, + ...intlOptions + } = options; + + const minimumFractionDigits = precision ?? (amount < 0.01 ? 6 : 4); + const maximumFractionDigits = precision ?? (amount < 0.01 ? 6 : 4); + + const formatOptions: Intl.NumberFormatOptions = { + style: 'currency', + currency, + minimumFractionDigits, + maximumFractionDigits, + ...(compact && amount >= 1000 && { notation: 'compact' }), + ...intlOptions + }; + + return new Intl.NumberFormat(locale, formatOptions).format(amount); + }, + + /** + * Format large currency amounts with compact notation + */ + compactCurrency: ( + amount: number | null | undefined, + options: CurrencyFormatOptions = {} + ): string => { + return formatters.currency(amount, { ...options, compact: true }); + }, + + /** + * Format percentages with consistent precision + */ + percentage: ( + value: number | null | undefined, + total?: number | null, + options: { decimals?: number; locale?: string } = {} + ): string => { + const { decimals = 1, locale = 'en-US' } = options; + + if (value === null || value === undefined || isNaN(value)) { + return '0%'; + } + + let percentage: number; + if (total !== undefined && total !== null && !isNaN(total)) { + if (total === 0) return '0%'; + percentage = (value / total) * 100; + } else { + percentage = value * 100; + } + + return new Intl.NumberFormat(locale, { + style: 'percent', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }).format(percentage / 100); + }, + + /** + * Format file sizes with appropriate units + */ + fileSize: ( + bytes: number | null | undefined, + options: { decimals?: number; binary?: boolean } = {} + ): string => { + if (bytes === null || bytes === undefined || isNaN(bytes) || bytes < 0) { + return '0 B'; + } + + const { decimals = 1, binary = false } = options; + const base = binary ? 1024 : 1000; + const units = binary + ? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'] + : ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + + if (bytes === 0) return '0 B'; + + const exp = Math.floor(Math.log(bytes) / Math.log(base)); + const unitIndex = Math.min(exp, units.length - 1); + const value = bytes / Math.pow(base, unitIndex); + + return `${value.toFixed(unitIndex === 0 ? 0 : decimals)} ${units[unitIndex]}`; + }, + + /** + * Format numbers with thousand separators and optional units + */ + number: ( + value: number | null | undefined, + options: NumberFormatOptions & { units?: string } = {} + ): string => { + if (value === null || value === undefined || isNaN(value)) { + return '0'; + } + + const { locale = 'en-US', compact = false, units, ...intlOptions } = options; + + const formatOptions: Intl.NumberFormatOptions = { + ...(compact && value >= 1000 && { notation: 'compact' }), + ...intlOptions + }; + + const formatted = new Intl.NumberFormat(locale, formatOptions).format(value); + return units ? `${formatted} ${units}` : formatted; + }, + + /** + * Format duration from milliseconds to human readable + */ + duration: ( + milliseconds: number | null | undefined, + options: { format?: 'long' | 'short' | 'compact' } = {} + ): string => { + if (milliseconds === null || milliseconds === undefined || isNaN(milliseconds) || milliseconds < 0) { + return '0ms'; + } + + const { format = 'short' } = options; + + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (format === 'compact') { + if (days > 0) return `${days}d`; + if (hours > 0) return `${hours}h`; + if (minutes > 0) return `${minutes}m`; + if (seconds > 0) return `${seconds}s`; + return `${milliseconds}ms`; + } + + if (format === 'long') { + const parts = []; + if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`); + if (hours % 24 > 0) parts.push(`${hours % 24} hour${hours % 24 !== 1 ? 's' : ''}`); + if (minutes % 60 > 0) parts.push(`${minutes % 60} minute${minutes % 60 !== 1 ? 's' : ''}`); + if (seconds % 60 > 0) parts.push(`${seconds % 60} second${seconds % 60 !== 1 ? 's' : ''}`); + return parts.join(', ') || '0 seconds'; + } + + // Short format (default) + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + if (seconds > 0) return `${seconds}s`; + return `${milliseconds}ms`; + }, + + /** + * Format API response times with appropriate units + */ + responseTime: (milliseconds: number | null | undefined): string => { + if (milliseconds === null || milliseconds === undefined || isNaN(milliseconds)) { + return '--'; + } + + if (milliseconds < 1000) { + return `${Math.round(milliseconds)}ms`; + } + + const seconds = milliseconds / 1000; + return `${seconds.toFixed(1)}s`; + }, + + /** + * Format large numbers with short notation (1.2M, 500K, etc) + */ + shortNumber: ( + value: number | null | undefined, + options: { decimals?: number; locale?: string } = {} + ): string => { + if (value === null || value === undefined || isNaN(value)) { + return '0'; + } + + const { decimals = 1 } = options; + + if (Math.abs(value) < 1000) { + return Math.round(value).toString(); + } + + const suffixes = ['', 'K', 'M', 'B', 'T']; + const absValue = Math.abs(value); + const exp = Math.min(Math.floor(Math.log10(absValue) / 3), suffixes.length - 1); + const shortValue = absValue / Math.pow(1000, exp); + + const formatted = shortValue.toFixed(decimals).replace(/\.0+$/, ''); + const suffix = suffixes[exp]; + + return value < 0 ? `-${formatted}${suffix}` : `${formatted}${suffix}`; + } +}; diff --git a/SDKs/Node/Common/src/formatting/index.ts b/SDKs/Node/Common/src/formatting/index.ts new file mode 100644 index 000000000..42859a2e3 --- /dev/null +++ b/SDKs/Node/Common/src/formatting/index.ts @@ -0,0 +1,2 @@ +export { formatters } from './formatters'; +export type { DateFormatOptions, CurrencyFormatOptions, NumberFormatOptions } from './types'; diff --git a/SDKs/Node/Common/src/formatting/types.ts b/SDKs/Node/Common/src/formatting/types.ts new file mode 100644 index 000000000..19b35f86b --- /dev/null +++ b/SDKs/Node/Common/src/formatting/types.ts @@ -0,0 +1,23 @@ +/** + * Type definitions for formatting utilities + */ + +export interface DateFormatOptions extends Intl.DateTimeFormatOptions { + locale?: string; + includeTime?: boolean; + includeSeconds?: boolean; + relativeDays?: number; +} + +export interface CurrencyFormatOptions extends Intl.NumberFormatOptions { + locale?: string; + currency?: string; + compact?: boolean; + precision?: number; +} + +export interface NumberFormatOptions extends Intl.NumberFormatOptions { + locale?: string; + compact?: boolean; + units?: string; +} diff --git a/SDKs/Node/Common/src/index.ts b/SDKs/Node/Common/src/index.ts index a3b37de7f..6ce764c20 100755 --- a/SDKs/Node/Common/src/index.ts +++ b/SDKs/Node/Common/src/index.ts @@ -52,6 +52,12 @@ export type { CustomDelaysConfig } from './client/retry-strategy'; +// Formatting utilities +export * from './formatting'; + +// Validation utilities (type guards, model patterns, form validators) +export * from './validation'; + // Circuit breaker types and classes export { CircuitState, diff --git a/SDKs/Node/Common/src/validation/form-validators.ts b/SDKs/Node/Common/src/validation/form-validators.ts new file mode 100644 index 000000000..81e627f72 --- /dev/null +++ b/SDKs/Node/Common/src/validation/form-validators.ts @@ -0,0 +1,108 @@ +/** + * Composable form validation functions + * + * Each validator returns `null` on success or an error message string on failure. + * These are framework-agnostic and work with any form library. + */ + +import { isValidIPv4, isValidCIDR } from './type-guards'; + +export const validators = { + required: (fieldName: string) => (value: string | undefined) => + !value?.trim() ? `${fieldName} is required` : null, + + minLength: (fieldName: string, min: number) => (value: string | undefined) => + (value?.length ?? 0) < min ? `${fieldName} must be at least ${min} characters` : null, + + maxLength: (fieldName: string, max: number) => (value: string | undefined) => + (value?.length ?? 0) > max ? `${fieldName} must be no more than ${max} characters` : null, + + positiveNumber: (fieldName: string) => (value: number | undefined) => + (value !== undefined && value < 0) ? `${fieldName} must be positive` : null, + + url: (value: string | undefined) => { + if (!value?.trim()) return null; + try { + new URL(value); + return null; + } catch { + return 'Must be a valid URL'; + } + }, + + email: (value: string | undefined) => { + if (!value?.trim()) return null; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value) ? null : 'Must be a valid email address'; + }, + + minValue: (fieldName: string, min: number) => (value: number | undefined) => + (value !== undefined && value < min) ? `${fieldName} must be at least ${min}` : null, + + ipAddresses: (value: string[] | undefined) => { + if (!value || value.length === 0) return null; + + for (const ip of value) { + if (!isValidIPv4(ip) && !isValidCIDR(ip)) { + return `Invalid IP address or CIDR: ${ip}`; + } + } + return null; + }, + + arrayMinLength: (fieldName: string, min: number) => (value: unknown[] | undefined) => + (!value || value.length < min) ? `At least ${min} ${fieldName} must be selected` : null, +}; + +/** + * Pre-configured validation combinations for common Conduit domain objects. + */ +export const commonValidations = { + name: { + validate: validators.required('Name'), + }, + + nameWithLength: (min = 3, max = 100) => ({ + validate: { + required: validators.required('Name'), + minLength: validators.minLength('Name', min), + maxLength: validators.maxLength('Name', max), + }, + }), + + description: { + validate: validators.maxLength('Description', 500), + }, + + apiKey: { + validate: validators.required('API Key'), + }, + + budget: { + validate: validators.positiveNumber('Budget'), + }, + + rateLimit: { + validate: validators.minValue('Rate limit', 1), + }, + + virtualKeyName: { + validate: { + required: validators.required('Key name'), + minLength: validators.minLength('Key name', 3), + maxLength: validators.maxLength('Key name', 100), + }, + }, + + allowedModels: { + validate: validators.arrayMinLength('model', 1), + }, + + allowedEndpoints: { + validate: validators.arrayMinLength('endpoint', 1), + }, + + ipAddresses: { + validate: validators.ipAddresses, + }, +}; diff --git a/SDKs/Node/Common/src/validation/index.ts b/SDKs/Node/Common/src/validation/index.ts new file mode 100644 index 000000000..94e1f7b20 --- /dev/null +++ b/SDKs/Node/Common/src/validation/index.ts @@ -0,0 +1,30 @@ +// Types +export type { ValidationError as FieldValidationError, ValidationResult, PatternValidationResult } from './types'; + +// Type guards +export { + isNonEmptyString, + isPositiveNumber, + isValidEmail, + isValidUrl, + isValidEnumValue, + isValidIPv4, + isValidCIDR, + isValidIPOrCIDR +} from './type-guards'; + +// Schema validator +export { createValidator } from './schema-validator'; + +// Model pattern utilities +export { + isValidModelPattern, + isPatternMatch, + getPatternExamples, + validatePatternSyntax, + normalizeModelPattern, + getPatternSpecificity +} from './model-patterns'; + +// Form validators +export { validators, commonValidations } from './form-validators'; diff --git a/SDKs/Node/Common/src/validation/model-patterns.ts b/SDKs/Node/Common/src/validation/model-patterns.ts new file mode 100644 index 000000000..5faff1c02 --- /dev/null +++ b/SDKs/Node/Common/src/validation/model-patterns.ts @@ -0,0 +1,118 @@ +/** + * Model pattern matching and validation utilities + * + * Used by the model cost mapping system to match model identifiers + * against wildcard patterns (e.g., "openai/gpt-4*"). + */ + +import type { PatternValidationResult } from './types'; + +/** + * Check whether a model pattern string contains only valid characters. + * Allows letters, numbers, hyphens, underscores, slashes, dots, spaces, and asterisks. + */ +export function isValidModelPattern(pattern: string): boolean { + if (!pattern || pattern.trim() === '') return false; + + const invalidChars = /[<>:"|?]/; + if (invalidChars.test(pattern)) return false; + + const validPattern = /^[a-zA-Z0-9\-_/.* ]+$/; + return validPattern.test(pattern); +} + +/** + * Test whether a wildcard pattern matches a given model identifier. + * `*` matches any sequence of characters; `?` matches a single character. + * Matching is case-insensitive. + */ +export function isPatternMatch(pattern: string, modelId: string): boolean { + if (!pattern || !modelId) return false; + + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + + const regex = new RegExp(`^${regexPattern}$`, 'i'); + return regex.test(modelId); +} + +/** + * Generate example model identifiers that would match a given pattern. + * Returns up to 3 examples. + */ +export function getPatternExamples(pattern: string): string[] { + const examples: string[] = []; + + if (pattern.includes('*')) { + if (pattern.startsWith('openai/')) { + examples.push('openai/gpt-4', 'openai/gpt-3.5-turbo', 'openai/text-embedding-ada-002'); + } else if (pattern.startsWith('anthropic/')) { + examples.push('anthropic/claude-3-opus', 'anthropic/claude-3-sonnet', 'anthropic/claude-3-haiku'); + } else if (pattern.includes('gpt-4')) { + examples.push('openai/gpt-4', 'openai/gpt-4-turbo', 'openai/gpt-4-32k'); + } else { + examples.push(`${pattern.replace('*', 'model-1')}`, `${pattern.replace('*', 'model-2')}`); + } + } else { + examples.push(pattern); + } + + return examples.slice(0, 3); +} + +/** + * Validate the syntax of a model pattern string. + * Checks for empty patterns, length limits, invalid characters, + * consecutive asterisks, and single-asterisk patterns. + */ +export function validatePatternSyntax(pattern: string): PatternValidationResult { + const errors: string[] = []; + + if (!pattern || pattern.trim() === '') { + errors.push('Pattern cannot be empty'); + return { isValid: false, errors }; + } + + if (pattern.length > 100) { + errors.push('Pattern cannot exceed 100 characters'); + } + + if (!isValidModelPattern(pattern)) { + errors.push('Pattern contains invalid characters'); + } + + if (pattern.includes('**')) { + errors.push('Pattern cannot contain consecutive asterisks'); + } + + if (pattern.startsWith('*') && pattern.length === 1) { + errors.push('Pattern cannot be a single asterisk'); + } + + return { isValid: errors.length === 0, errors }; +} + +/** + * Normalize a model pattern by trimming whitespace and lowercasing. + */ +export function normalizeModelPattern(pattern: string): string { + return pattern.trim().toLowerCase(); +} + +/** + * Calculate pattern specificity. Higher values indicate more specific patterns. + * Exact patterns score 100; wildcards reduce the score. + */ +export function getPatternSpecificity(pattern: string): number { + if (!pattern.includes('*')) { + return 100; + } + + const wildcardCount = (pattern.match(/\*/g) ?? []).length; + const firstWildcardPos = pattern.indexOf('*'); + const positionWeight = firstWildcardPos === 0 ? 20 : Math.max(0, 20 - firstWildcardPos); + + return Math.max(0, 100 - (wildcardCount * 10) - positionWeight); +} diff --git a/SDKs/Node/Common/src/validation/schema-validator.ts b/SDKs/Node/Common/src/validation/schema-validator.ts new file mode 100644 index 000000000..ba0469f0c --- /dev/null +++ b/SDKs/Node/Common/src/validation/schema-validator.ts @@ -0,0 +1,54 @@ +/** + * Schema-based request body validator + */ + +import type { ValidationError, ValidationResult } from './types'; + +/** + * Create a type-safe validator from a schema of per-field validation functions. + * + * @example + * ```typescript + * const validate = createValidator<{ name: string; age: number }>({ + * name: isNonEmptyString, + * age: isPositiveNumber, + * }); + * const result = validate(requestBody); + * if (result.isValid) { ... } + * ``` + */ +export function createValidator( + schema: Record boolean> +): (body: unknown) => ValidationResult { + return (body: unknown): ValidationResult => { + if (!body || typeof body !== 'object') { + return { + isValid: false, + errors: [{ field: 'body', message: 'Request body must be an object' }] + }; + } + + const errors: ValidationError[] = []; + const validatedData = {} as T; + const bodyObj = body as Record; + + for (const [field, validator] of Object.entries(schema)) { + const value = bodyObj[field]; + const validatorFn = validator as (value: unknown) => boolean; + if (!validatorFn(value)) { + errors.push({ + field, + message: `Invalid value for field: ${field}` + }); + } else { + (validatedData as Record)[field] = value; + } + } + + if (errors.length > 0) { + return { isValid: false, errors }; + } + + return { isValid: true, data: validatedData }; + }; +} diff --git a/SDKs/Node/Common/src/validation/type-guards.ts b/SDKs/Node/Common/src/validation/type-guards.ts new file mode 100644 index 000000000..6c491838d --- /dev/null +++ b/SDKs/Node/Common/src/validation/type-guards.ts @@ -0,0 +1,72 @@ +/** + * Common type guard and validation utilities + */ + +/** + * Check that a value is a non-empty string (after trimming). + */ +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Check that a value is a positive number (> 0). + */ +export function isPositiveNumber(value: unknown): value is number { + return typeof value === 'number' && !isNaN(value) && value > 0; +} + +/** + * Check that a value looks like a valid email address. + */ +export function isValidEmail(value: unknown): value is string { + if (!isNonEmptyString(value)) return false; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value); +} + +/** + * Check that a value is a valid URL. + */ +export function isValidUrl(value: unknown): value is string { + if (!isNonEmptyString(value)) return false; + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** + * Check that a value is one of a set of allowed enum strings. + */ +export function isValidEnumValue( + value: unknown, + enumValues: readonly T[] +): value is T { + return typeof value === 'string' && enumValues.includes(value as T); +} + +/** + * Check that a value is a valid IPv4 address. + */ +export function isValidIPv4(value: string): boolean { + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + return ipRegex.test(value); +} + +/** + * Check that a value is a valid CIDR notation (IPv4). + */ +export function isValidCIDR(value: string): boolean { + const cidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(?:3[0-2]|[12]?[0-9])$/; + return cidrRegex.test(value); +} + +/** + * Check that a value is a valid IPv4 address or CIDR notation. + */ +export function isValidIPOrCIDR(value: string): boolean { + return isValidIPv4(value) || isValidCIDR(value); +} diff --git a/SDKs/Node/Common/src/validation/types.ts b/SDKs/Node/Common/src/validation/types.ts new file mode 100644 index 000000000..c66899407 --- /dev/null +++ b/SDKs/Node/Common/src/validation/types.ts @@ -0,0 +1,19 @@ +/** + * Type definitions for validation utilities + */ + +export interface ValidationError { + field: string; + message: string; +} + +export interface ValidationResult { + isValid: boolean; + data?: T; + errors?: ValidationError[]; +} + +export interface PatternValidationResult { + isValid: boolean; + errors: string[]; +} diff --git a/SDKs/Node/Gateway/package.json b/SDKs/Node/Gateway/package.json index aa09d0786..eebe5d0ca 100755 --- a/SDKs/Node/Gateway/package.json +++ b/SDKs/Node/Gateway/package.json @@ -48,28 +48,28 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/knnlabs/Conduit.git", + "url": "https://github.com/nickna/Conduit.git", "directory": "SDKs/Node/Gateway" }, "bugs": { - "url": "https://github.com/knnlabs/Conduit/issues" + "url": "https://github.com/nickna/Conduit/issues" }, - "homepage": "https://github.com/knnlabs/Conduit#readme", + "homepage": "https://github.com/nickna/Conduit#readme", "dependencies": { "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" + "@microsoft/signalr": "^10.0.0" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.1.1", - "ts-jest": "^29.1.1", + "@types/node": "^25.0.3", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^9.39.2", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "typescript": "^5.8.3" + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "engines": { "node": ">=16.0.0" diff --git a/SDKs/Node/Gateway/src/chat/streaming/streaming-circuit-breaker.ts b/SDKs/Node/Gateway/src/chat/streaming/streaming-circuit-breaker.ts index 82af5ae38..19a67cef5 100644 --- a/SDKs/Node/Gateway/src/chat/streaming/streaming-circuit-breaker.ts +++ b/SDKs/Node/Gateway/src/chat/streaming/streaming-circuit-breaker.ts @@ -9,10 +9,11 @@ import { CircuitBreaker, - CircuitState, - isCircuitBreakerOpenError + isCircuitBreakerOpenError, + type CircuitBreakerStats, + type CircuitBreakerCallbacks, + type CircuitState } from '@knn_labs/conduit-common'; -import type { CircuitBreakerStats, CircuitBreakerCallbacks } from '@knn_labs/conduit-common'; import type { StreamingCircuitBreakerConfig, CircuitBreakerEvent, StreamingError } from './types'; /** diff --git a/SDKs/Node/Gateway/src/index.ts b/SDKs/Node/Gateway/src/index.ts index 6cd4457a2..b2d6c5fc2 100755 --- a/SDKs/Node/Gateway/src/index.ts +++ b/SDKs/Node/Gateway/src/index.ts @@ -28,6 +28,7 @@ export type { EnhancedSSEEventType, StreamingMetrics, FinalMetrics, + StreamingErrorEvent, ReasoningEvent, ToolExecutingEvent, ToolResultEvent, @@ -41,6 +42,7 @@ export { isChatCompletionChunk, isStreamingMetrics, isFinalMetrics, + isStreamingErrorEvent, isReasoningEvent, isToolExecutingEvent, isToolResultEvent, diff --git a/SDKs/Node/Gateway/src/models/enhanced-streaming.ts b/SDKs/Node/Gateway/src/models/enhanced-streaming.ts index 6d3dcbb95..6e098f3e0 100755 --- a/SDKs/Node/Gateway/src/models/enhanced-streaming.ts +++ b/SDKs/Node/Gateway/src/models/enhanced-streaming.ts @@ -302,6 +302,51 @@ export interface ToolResultEvent { error?: string; } +/** + * Error event data - sent as "event: error" when a provider or streaming error occurs. + * Contains the error message from the upstream provider or internal processing. + * + * @interface StreamingErrorEvent + * @since 0.5.0 + * + * @example + * ```typescript + * { + * error: 'Model gpt-oss-120b does not exist or you do not have access to it.' + * } + * ``` + */ +export interface StreamingErrorEvent { + /** Error message describing what went wrong */ + error: string; +} + +/** + * Type guard to check if data is a StreamingErrorEvent. + * Detects error events sent by the backend during streaming when a provider + * returns an error (e.g., model not found, rate limit, auth failure). + * + * @param {unknown} data - The data to check + * @returns {boolean} True if data is a StreamingErrorEvent + * @since 0.5.0 + * + * @example + * ```typescript + * if (isStreamingErrorEvent(event.data)) { + * console.error('Streaming error:', event.data.error); + * } + * ``` + */ +export function isStreamingErrorEvent(data: unknown): data is StreamingErrorEvent { + return ( + typeof data === 'object' && + data !== null && + 'error' in data && + typeof (data as Record).error === 'string' && + !('object' in data) // Distinguish from OpenAI error responses that have both 'error' and 'object' + ); +} + /** * Type guard to check if data is a ReasoningEvent. * diff --git a/SDKs/Node/Gateway/src/models/providerType.ts b/SDKs/Node/Gateway/src/models/providerType.ts index 335033ca4..cc63e5c02 100755 --- a/SDKs/Node/Gateway/src/models/providerType.ts +++ b/SDKs/Node/Gateway/src/models/providerType.ts @@ -34,5 +34,11 @@ export enum ProviderType { SambaNova = 10, /** DeepInfra (OpenAI-compatible LLM inference platform) */ - DeepInfra = 11 + DeepInfra = 11, + + /** Cloudflare Workers AI (serverless AI inference on Cloudflare's global network) */ + Cloudflare = 12, + + /** OpenRouter (multi-provider routing via OpenAI-compatible API) */ + OpenRouter = 13 } \ No newline at end of file diff --git a/SDKs/Node/Gateway/src/services/BatchOperationsService.ts b/SDKs/Node/Gateway/src/services/BatchOperationsService.ts index 375c2bd66..2718275a3 100755 --- a/SDKs/Node/Gateway/src/services/BatchOperationsService.ts +++ b/SDKs/Node/Gateway/src/services/BatchOperationsService.ts @@ -351,7 +351,7 @@ export class BatchOperationsService { } } - if (update.allowedModels && update.allowedModels.length === 0) { + if (update.allowedModels?.length === 0) { warnings.push(`Empty allowedModels array at index ${index}. This will remove all model restrictions`); } }); diff --git a/SDKs/Node/Gateway/src/utils/error-handling.ts b/SDKs/Node/Gateway/src/utils/error-handling.ts index 54df3a259..5605b1a80 100644 --- a/SDKs/Node/Gateway/src/utils/error-handling.ts +++ b/SDKs/Node/Gateway/src/utils/error-handling.ts @@ -100,6 +100,15 @@ export function getErrorDisplayMessage(error: unknown, context?: string): string } if (error instanceof ServerError) { + // Include the actual error message if available and informative + const errorMessage = error.message; + const hasUsefulMessage = errorMessage && + errorMessage !== 'Internal server error' && + !errorMessage.includes('Unknown error'); + + if (hasUsefulMessage) { + return `๐Ÿ”ง ${errorMessage}`; + } return `๐Ÿ”ง Server error occurred. ${context ? `Failed to ${context}.` : ''} Please try again later.`; } diff --git a/SDKs/Node/package-lock.json b/SDKs/Node/package-lock.json index f5f5527d4..5b27b1802 100644 --- a/SDKs/Node/package-lock.json +++ b/SDKs/Node/package-lock.json @@ -24,21 +24,21 @@ "license": "MIT", "dependencies": { "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" + "@microsoft/signalr": "^10.0.0" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@types/react": "^19.1.8", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.0.4", - "prettier": "^3.0.0", - "ts-jest": "^29.1.0", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^9.39.2", + "jest": "^30.2.0", + "prettier": "^3.7.4", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "tsup": "^8.0.0", - "typescript": "^5.8.3" + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "engines": { "node": ">=16.0.0" @@ -57,89 +57,50 @@ "version": "0.2.0", "license": "MIT", "dependencies": { - "@microsoft/signalr": "^8.0.7", - "@microsoft/signalr-protocol-msgpack": "^8.0.7" + "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr-protocol-msgpack": "^10.0.0" }, "devDependencies": { - "@types/node": "^24.0.15", - "tsup": "^8.1.0", - "typescript": "^5.8.3" + "@types/node": "^25.0.3", + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "peerDependencies": { "typescript": ">=4.5.0" } }, - "Core": { - "name": "@knn_labs/conduit-core-client", - "version": "0.2.1", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" - }, - "devDependencies": { - "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.1.1", - "ts-jest": "^29.1.1", - "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "typescript": "^5.8.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, "Gateway": { "name": "@knn_labs/conduit-gateway-client", "version": "0.2.1", "license": "MIT", "dependencies": { "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" + "@microsoft/signalr": "^10.0.0" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.1.1", - "ts-jest": "^29.1.1", + "@types/node": "^25.0.3", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^9.39.2", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "typescript": "^5.8.3" + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "engines": { "node": ">=16.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -148,9 +109,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -158,22 +119,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", - "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.3", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -199,14 +160,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -216,13 +177,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -253,29 +214,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -285,9 +246,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -305,9 +266,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -325,27 +286,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", - "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -410,13 +371,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -452,13 +413,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -578,13 +539,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -594,33 +555,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -628,14 +589,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -673,9 +634,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -685,9 +646,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -707,9 +668,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -724,9 +685,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -741,9 +702,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -758,9 +719,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -775,9 +736,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -792,9 +753,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -809,9 +770,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -826,9 +787,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -843,9 +804,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -860,9 +821,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -877,9 +838,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -894,9 +855,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -911,9 +872,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -928,9 +889,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -945,9 +906,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -962,9 +923,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -979,9 +940,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -996,9 +957,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -1013,9 +974,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -1030,9 +991,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -1047,9 +1008,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -1064,9 +1025,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -1081,9 +1042,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -1098,9 +1059,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1115,9 +1076,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1132,9 +1093,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1149,9 +1110,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1168,9 +1129,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -1178,13 +1139,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1192,6 +1153,13 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1204,9 +1172,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1217,19 +1185,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1240,20 +1211,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1263,6 +1234,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1285,9 +1263,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1298,9 +1276,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.34.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", - "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1311,9 +1289,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1321,13 +1299,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1345,33 +1323,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1460,9 +1424,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -1536,17 +1500,17 @@ } }, "node_modules/@jest/console": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.1.1.tgz", - "integrity": "sha512-f7TGqR1k4GtN5pyFrKmq+ZVndesiwLU33yDpJIGMS9aW+j6hKjue7ljeAdznBsH9kAnxUWe2Y+Y3fLV/FJt3gA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -1554,39 +1518,39 @@ } }, "node_modules/@jest/core": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.1.1.tgz", - "integrity": "sha512-3ncU9peZ3D2VdgRkdZtUceTrDgX5yiDRwAFjtxNfU22IiZrpVWlv/FogzDLYSJQptQGfFo3PcHK86a2oG6WUGg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.1", + "@jest/console": "30.2.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.1.1", - "@jest/test-result": "30.1.1", - "@jest/transform": "30.1.1", - "@jest/types": "30.0.5", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.0.5", - "jest-config": "30.1.1", - "jest-haste-map": "30.1.0", - "jest-message-util": "30.1.0", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.0", - "jest-resolve-dependencies": "30.1.1", - "jest-runner": "30.1.1", - "jest-runtime": "30.1.1", - "jest-snapshot": "30.1.1", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", - "jest-watcher": "30.1.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", "micromatch": "^4.0.8", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -1612,39 +1576,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.1.tgz", - "integrity": "sha512-yWHbU+3j7ehQE+NRpnxRvHvpUhoohIjMePBbIr8lfe0cWVb0WeTf80DNux1GPJa18CDHiIU5DtksGUfxcDE+Rw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.1.1", - "@jest/types": "30.0.5", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "30.0.5" + "jest-mock": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.1.1.tgz", - "integrity": "sha512-3vHIHsF+qd3D8FU2c7U5l3rg1fhDwAYcGyHyZAi94YIlTwcJ+boNhRyJf373cl4wxbOX+0Q7dF40RTrTFTSuig==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.1.1", - "jest-snapshot": "30.1.1" + "expect": "30.2.0", + "jest-snapshot": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.1.tgz", - "integrity": "sha512-5YUHr27fpJ64dnvtu+tt11ewATynrHkGYD+uSFgRr8V2eFJis/vEXgToyLwccIwqBihVfz9jwio+Zr1ab1Zihw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, "license": "MIT", "dependencies": { @@ -1655,18 +1619,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.1.tgz", - "integrity": "sha512-fK/25dNgBNYPw3eLi2CRs57g1H04qBAFNMsUY3IRzkfx/m4THe0E1zF+yGQBOMKKc2XQVdc9EYbJ4hEm7/2UtA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1683,16 +1647,16 @@ } }, "node_modules/@jest/globals": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.1.1.tgz", - "integrity": "sha512-NNUUkHT2TU/xztZl6r1UXvJL+zvCwmZsQDmK69fVHHcB9fBtlu3FInnzOve/ZoyKnWY8JXWJNT+Lkmu1+ubXUA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.1", - "@jest/expect": "30.1.1", - "@jest/types": "30.0.5", - "jest-mock": "30.0.5" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1713,17 +1677,17 @@ } }, "node_modules/@jest/reporters": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.1.1.tgz", - "integrity": "sha512-Hb2Bq80kahOC6Sv2waEaH1rEU6VdFcM6WHaRBWQF9tf30+nJHxhl/Upbgo9+25f0mOgbphxvbwSMjSgy9gW/FA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.1.1", - "@jest/test-result": "30.1.1", - "@jest/transform": "30.1.1", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -1736,9 +1700,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1769,13 +1733,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.1.tgz", - "integrity": "sha512-TkVBc9wuN22TT8hESRFmjjg/xIMu7z0J3UDYtIRydzCqlLPTB7jK1DDBKdnTUZ4zL3z3rnPpzV6rL1Uzh87sXg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1800,14 +1764,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.1.1.tgz", - "integrity": "sha512-bMdj7fNu8iZuBPSnbVir5ezvWmVo4jrw7xDE+A33Yb3ENCoiJK9XgOLgal+rJ9XSKjsL7aPUMIo87zhN7I5o2w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.1", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1816,15 +1780,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.1.1.tgz", - "integrity": "sha512-yruRdLXSA3HYD/MTNykgJ6VYEacNcXDFRMqKVAwlYegmxICUiT/B++CNuhJnYJzKYks61iYnjVsMwbUqmmAYJg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.1.1", + "@jest/test-result": "30.2.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -1832,23 +1796,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.1.1.tgz", - "integrity": "sha512-PHIA2AbAASBfk6evkNifvmx9lkOSkmvaQoO6VSpuL8+kQqDMHeDoJ7RU3YP1wWAMD7AyQn9UL5iheuFYCC4lqQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.0", + "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "micromatch": "^4.0.8", "pirates": "^4.0.7", "slash": "^3.0.0", @@ -1859,9 +1823,9 @@ } }, "node_modules/@jest/types": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", - "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", "dependencies": { @@ -1888,6 +1852,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1906,9 +1881,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1929,9 +1904,9 @@ "link": true }, "node_modules/@microsoft/signalr": { - "version": "8.0.17", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz", - "integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -1942,12 +1917,12 @@ } }, "node_modules/@microsoft/signalr-protocol-msgpack": { - "version": "8.0.17", - "resolved": "https://registry.npmjs.org/@microsoft/signalr-protocol-msgpack/-/signalr-protocol-msgpack-8.0.17.tgz", - "integrity": "sha512-mT7jhnK7r/KdXLnhXvXuA156nIVFwQnurOg4rbat5YTb+ribtmNVBni6XQl16pfWLGCF+VC0e+f8NJzZGJCGiA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr-protocol-msgpack/-/signalr-protocol-msgpack-10.0.0.tgz", + "integrity": "sha512-N4h4BD+y9kw/iszpDaDaIRJpxaRSA5uBtveM6HUIwmwkeJIPOoMrPNvmj77UrjZHAsbVwa/acLiWnPDfffO3yQ==", "license": "MIT", "dependencies": { - "@microsoft/signalr": ">=8.0.17", + "@microsoft/signalr": ">=10.0.0", "@msgpack/msgpack": "^2.7.0" } }, @@ -1973,44 +1948,6 @@ "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2036,9 +1973,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.0.tgz", - "integrity": "sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -2050,9 +1987,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.0.tgz", - "integrity": "sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -2064,9 +2001,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.0.tgz", - "integrity": "sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -2078,9 +2015,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.0.tgz", - "integrity": "sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -2092,9 +2029,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.0.tgz", - "integrity": "sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -2106,9 +2043,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.0.tgz", - "integrity": "sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -2120,9 +2057,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.0.tgz", - "integrity": "sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -2134,9 +2071,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.0.tgz", - "integrity": "sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -2148,9 +2085,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.0.tgz", - "integrity": "sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -2162,9 +2099,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.0.tgz", - "integrity": "sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -2175,10 +2112,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.0.tgz", - "integrity": "sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -2190,9 +2141,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.0.tgz", - "integrity": "sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -2204,9 +2169,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.0.tgz", - "integrity": "sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -2218,9 +2183,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.0.tgz", - "integrity": "sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -2232,9 +2197,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.0.tgz", - "integrity": "sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -2246,9 +2211,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.0.tgz", - "integrity": "sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -2260,9 +2225,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.0.tgz", - "integrity": "sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -2273,10 +2238,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.0.tgz", - "integrity": "sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2288,9 +2267,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.0.tgz", - "integrity": "sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2302,9 +2281,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.0.tgz", - "integrity": "sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2315,10 +2294,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.0.tgz", - "integrity": "sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2330,9 +2323,9 @@ ] }, "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, @@ -2357,9 +2350,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -2385,9 +2378,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -2493,23 +2486,23 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", + "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { - "version": "19.1.12", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", - "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/stack-utils": { @@ -2520,9 +2513,9 @@ "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2537,21 +2530,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", - "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/type-utils": "8.41.0", - "@typescript-eslint/utils": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2561,23 +2553,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.41.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", - "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2587,20 +2579,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", - "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.41.0", - "@typescript-eslint/types": "^8.41.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2614,14 +2606,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", - "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2632,9 +2624,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", - "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, "license": "MIT", "engines": { @@ -2649,17 +2641,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", - "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/utils": "8.41.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2669,14 +2661,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", - "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, "license": "MIT", "engines": { @@ -2688,22 +2680,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", - "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.41.0", - "@typescript-eslint/tsconfig-utils": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2717,16 +2708,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", - "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2736,19 +2727,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", - "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.41.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2759,13 +2750,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3060,9 +3051,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -3083,9 +3074,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -3096,9 +3087,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3129,9 +3120,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", - "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -3193,16 +3184,16 @@ "license": "Python-2.0" }, "node_modules/babel-jest": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.1.tgz", - "integrity": "sha512-1bZfC/V03qBCzASvZpNFhx3Ouj6LgOd4KFJm4br/fYOS+tSSvVCE61QmcAVbMTwq/GoB7KN4pzGMoyr9cMxSvQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.1.1", + "@jest/transform": "30.2.0", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.0", - "babel-preset-jest": "30.0.1", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -3211,15 +3202,18 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "node_modules/babel-plugin-istanbul": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", - "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -3232,14 +3226,12 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", - "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", "@types/babel__core": "^7.20.5" }, "engines": { @@ -3274,37 +3266,56 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", - "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.0.1", - "babel-preset-current-node-syntax": "^1.1.0" + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -3321,9 +3332,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", - "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3341,10 +3352,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001737", - "electron-to-chromium": "^1.5.211", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3430,9 +3442,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001739", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", - "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, "funding": [ { @@ -3494,9 +3506,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -3510,9 +3522,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", - "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, "license": "MIT" }, @@ -3606,9 +3618,9 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, @@ -3696,16 +3708,16 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true, "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3721,9 +3733,9 @@ } }, "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3763,9 +3775,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3780,9 +3792,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.211", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", - "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -3807,9 +3819,9 @@ "license": "MIT" }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3817,9 +3829,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3830,32 +3842,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -3882,25 +3894,24 @@ } }, "node_modules/eslint": { - "version": "9.34.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", - "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.34.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -3972,6 +3983,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4007,9 +4025,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4065,9 +4083,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4170,18 +4188,18 @@ } }, "node_modules/expect": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.1.tgz", - "integrity": "sha512-OKe7cdic4qbfWd/CcgwJvvCrNX2KWfuMZee9AfJHL1gTYmvqjBjZG1a2NwfhspBzxzlXwsN75WWpKTYfsJpBxg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.1.1", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.1.1", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4194,36 +4212,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4238,16 +4226,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4427,9 +4405,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -4460,6 +4439,22 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", + "integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4480,13 +4475,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -4780,16 +4768,16 @@ } }, "node_modules/jest": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.1.1.tgz", - "integrity": "sha512-yC3JvpP/ZcAZX5rYCtXO/g9k6VTCQz0VFE2v1FpxytWzUqfDtu0XL/pwnNvptzYItvGwomh1ehomRNMOyhCJKw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.1.1", - "@jest/types": "30.0.5", + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", "import-local": "^3.2.0", - "jest-cli": "30.1.1" + "jest-cli": "30.2.0" }, "bin": { "jest": "bin/jest.js" @@ -4807,14 +4795,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.5.tgz", - "integrity": "sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "p-limit": "^3.1.0" }, "engines": { @@ -4822,29 +4810,29 @@ } }, "node_modules/jest-circus": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.1.1.tgz", - "integrity": "sha512-M3Vd4x5wD7eSJspuTvRF55AkOOBndRxgW3gqQBDlFvbH3X+ASdi8jc+EqXEeAFd/UHulVYIlC4XKJABOhLw6UA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.1", - "@jest/expect": "30.1.1", - "@jest/test-result": "30.1.1", - "@jest/types": "30.0.5", + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.1.0", - "jest-matcher-utils": "30.1.1", - "jest-message-util": "30.1.0", - "jest-runtime": "30.1.1", - "jest-snapshot": "30.1.1", - "jest-util": "30.0.5", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "p-limit": "^3.1.0", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -4854,21 +4842,21 @@ } }, "node_modules/jest-cli": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.1.1.tgz", - "integrity": "sha512-xm9llxuh5OoI5KZaYzlMhklryHBwg9LZy/gEaaMlXlxb+cZekGNzukU0iblbDo3XOBuN6N0CgK4ykgNRYSEb6g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.1.1", - "@jest/test-result": "30.1.1", - "@jest/types": "30.0.5", + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.1.1", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "yargs": "^17.7.2" }, "bin": { @@ -4887,34 +4875,34 @@ } }, "node_modules/jest-config": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.1.1.tgz", - "integrity": "sha512-xuPGUGDw+9fPPnGmddnLnHS/mhKUiJOW7K65vErYmglEPKq65NKwSRchkQ7iv6gqjs2l+YNEsAtbsplxozdOWg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.1.1", - "@jest/types": "30.0.5", - "babel-jest": "30.1.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-circus": "30.1.1", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.1", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.0", - "jest-runner": "30.1.1", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -4939,25 +4927,25 @@ } }, "node_modules/jest-diff": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.1.tgz", - "integrity": "sha512-LUU2Gx8EhYxpdzTR6BmjL1ifgOAQJQELTHOiPv9KITaKjZvJ9Jmgigx01tuZ49id37LorpGc9dPBPlXTboXScw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", - "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", "dependencies": { @@ -4968,56 +4956,56 @@ } }, "node_modules/jest-each": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.1.0.tgz", - "integrity": "sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "chalk": "^4.1.2", - "jest-util": "30.0.5", - "pretty-format": "30.0.5" + "jest-util": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.1.1.tgz", - "integrity": "sha512-IaMoaA6saxnJimqCppUDqKck+LKM0Jg+OxyMUIvs1yGd2neiC22o8zXo90k04+tO+49OmgMR4jTgM5e4B0S62Q==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.1", - "@jest/fake-timers": "30.1.1", - "@jest/types": "30.0.5", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "30.0.5", - "jest-util": "30.0.5", - "jest-validate": "30.1.0" + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.1.0.tgz", - "integrity": "sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "micromatch": "^4.0.8", "walker": "^1.0.8" }, @@ -5029,49 +5017,49 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.1.0.tgz", - "integrity": "sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.1.tgz", - "integrity": "sha512-SuH2QVemK48BNTqReti6FtjsMPFsSOD/ZzRxU1TttR7RiRsRSe78d03bb4Cx6D4bQC/80Q8U4VnaaAH9FlbZ9w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.1.1", - "pretty-format": "30.0.5" + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", - "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -5080,15 +5068,15 @@ } }, "node_modules/jest-mock": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", - "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-util": "30.0.5" + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5123,18 +5111,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.1.0.tgz", - "integrity": "sha512-hASe7D/wRtZw8Cm607NrlF7fi3HWC5wmA5jCVc2QjQAB2pTwP9eVZILGEi6OeSLNUtE1zb04sXRowsdh5CUjwA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -5143,46 +5131,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.1.1.tgz", - "integrity": "sha512-tRtaaoH8Ws1Gn1o/9pedt19dvVgr81WwdmvJSP9Ow3amOUOP2nN9j94u5jC9XlIfa2Q1FQKIWWQwL4ajqsjCGQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.1.1" + "jest-snapshot": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.1.1.tgz", - "integrity": "sha512-ATe6372SOfJvCRExtCAr06I4rGujwFdKg44b6i7/aOgFnULwjxzugJ0Y4AnG+jeSeQi8dU7R6oqLGmsxRUbErQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.1", - "@jest/environment": "30.1.1", - "@jest/test-result": "30.1.1", - "@jest/transform": "30.1.1", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.1", - "jest-haste-map": "30.1.0", - "jest-leak-detector": "30.1.0", - "jest-message-util": "30.1.0", - "jest-resolve": "30.1.0", - "jest-runtime": "30.1.1", - "jest-util": "30.0.5", - "jest-watcher": "30.1.1", - "jest-worker": "30.1.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -5191,32 +5179,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.1.1.tgz", - "integrity": "sha512-7sOyR0Oekw4OesQqqBHuYJRB52QtXiq0NNgLRzVogiMSxKCMiliUd6RrXHCnG5f12Age/ggidCBiQftzcA9XKw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.1", - "@jest/fake-timers": "30.1.1", - "@jest/globals": "30.1.1", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.1.1", - "@jest/transform": "30.1.1", - "@jest/types": "30.0.5", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.0", - "jest-snapshot": "30.1.1", - "jest-util": "30.0.5", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -5225,9 +5213,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.1.tgz", - "integrity": "sha512-7/iBEzoJqEt2TjkQY+mPLHP8cbPhLReZVkkxjTMzIzoTC4cZufg7HzKo/n9cIkXKj2LG0x3mmBHsZto+7TOmFg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", "dependencies": { @@ -5236,20 +5224,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.1.1", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.1.1", - "@jest/transform": "30.1.1", - "@jest/types": "30.0.5", - "babel-preset-current-node-syntax": "^1.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.1.1", + "expect": "30.2.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.1.1", - "jest-matcher-utils": "30.1.1", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "pretty-format": "30.0.5", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -5258,13 +5246,13 @@ } }, "node_modules/jest-util": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", - "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -5289,18 +5277,18 @@ } }, "node_modules/jest-validate": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.1.0.tgz", - "integrity": "sha512-7P3ZlCFW/vhfQ8pE7zW6Oi4EzvuB4sgR72Q1INfW9m0FGo0GADYlPwIkf4CyPq7wq85g+kPMtPOHNAdWHeBOaA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5320,19 +5308,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.1.1.tgz", - "integrity": "sha512-CrAQ73LlaS6KGQQw6NBi71g7qvP7scy+4+2c0jKX6+CWaYg85lZiig5nQQVTsS5a5sffNPL3uxXnaE9d7v9eQg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.1.1", - "@jest/types": "30.0.5", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "string-length": "^4.0.2" }, "engines": { @@ -5340,15 +5328,15 @@ } }, "node_modules/jest-worker": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.1.0.tgz", - "integrity": "sha512-uvWcSjlwAAgIu133Tt77A05H7RIk3Ho8tZL50bQM2AkvLdluw9NG48lRCl3Dt+MOH719n/0nnb5YxUwcuJiKRA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -5390,9 +5378,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5550,13 +5538,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5568,9 +5549,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.18", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", - "integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5617,16 +5598,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5652,16 +5623,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5678,11 +5649,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -5720,9 +5691,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { @@ -5777,9 +5748,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -6167,9 +6138,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -6183,9 +6154,9 @@ } }, "node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -6254,27 +6225,6 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -6345,21 +6295,10 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz", - "integrity": "sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -6373,58 +6312,38 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.50.0", - "@rollup/rollup-android-arm64": "4.50.0", - "@rollup/rollup-darwin-arm64": "4.50.0", - "@rollup/rollup-darwin-x64": "4.50.0", - "@rollup/rollup-freebsd-arm64": "4.50.0", - "@rollup/rollup-freebsd-x64": "4.50.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.50.0", - "@rollup/rollup-linux-arm-musleabihf": "4.50.0", - "@rollup/rollup-linux-arm64-gnu": "4.50.0", - "@rollup/rollup-linux-arm64-musl": "4.50.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.50.0", - "@rollup/rollup-linux-ppc64-gnu": "4.50.0", - "@rollup/rollup-linux-riscv64-gnu": "4.50.0", - "@rollup/rollup-linux-riscv64-musl": "4.50.0", - "@rollup/rollup-linux-s390x-gnu": "4.50.0", - "@rollup/rollup-linux-x64-gnu": "4.50.0", - "@rollup/rollup-linux-x64-musl": "4.50.0", - "@rollup/rollup-openharmony-arm64": "4.50.0", - "@rollup/rollup-win32-arm64-msvc": "4.50.0", - "@rollup/rollup-win32-ia32-msvc": "4.50.0", - "@rollup/rollup-win32-x64-msvc": "4.50.0", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6435,9 +6354,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/shebang-command": { @@ -6639,9 +6558,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -6712,18 +6631,18 @@ } }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -6748,9 +6667,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6778,6 +6697,13 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/test-exclude/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -6793,7 +6719,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -6812,9 +6738,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6855,14 +6781,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -6954,9 +6880,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -6974,9 +6900,9 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.4.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", - "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -6986,7 +6912,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -7092,9 +7018,9 @@ "optional": true }, "node_modules/tsup": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", - "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", "dev": true, "license": "MIT", "dependencies": { @@ -7103,14 +7029,14 @@ "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", - "source-map": "0.8.0-beta.0", + "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", @@ -7155,46 +7081,13 @@ } }, "node_modules/tsup/node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, "engines": { - "node": ">= 8" - } - }, - "node_modules/tsup/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/tsup/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/tsup/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" + "node": ">= 12" } }, "node_modules/type-check": { @@ -7234,9 +7127,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7248,9 +7141,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "dev": true, "license": "MIT" }, @@ -7269,9 +7162,9 @@ } }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -7320,9 +7213,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -7534,9 +7427,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { diff --git a/SDKs/Node/scripts/GenerateOpenApiSpecs/GenerateOpenApiSpecs.csproj b/SDKs/Node/scripts/GenerateOpenApiSpecs/GenerateOpenApiSpecs.csproj index 105e50de0..7fddfc02f 100755 --- a/SDKs/Node/scripts/GenerateOpenApiSpecs/GenerateOpenApiSpecs.csproj +++ b/SDKs/Node/scripts/GenerateOpenApiSpecs/GenerateOpenApiSpecs.csproj @@ -7,7 +7,7 @@ - + diff --git a/SDKs/Node/scripts/generate-openapi-from-build.sh b/SDKs/Node/scripts/generate-openapi-from-build.sh index 11073997a..b63852e5c 100755 --- a/SDKs/Node/scripts/generate-openapi-from-build.sh +++ b/SDKs/Node/scripts/generate-openapi-from-build.sh @@ -337,7 +337,7 @@ main() { log "${GREEN}๐Ÿ“‹ Summary:${NC}" log "${GREEN} - Gateway API: Services/ConduitLLM.Gateway/openapi-gateway.json${NC}" log "${GREEN} - Admin API: Services/ConduitLLM.Admin/openapi-admin.json${NC}" - log "${GREEN} - Core SDK: SDKs/Node/Core/src/generated/gateway-api.ts${NC}" + log "${GREEN} - Gateway SDK: SDKs/Node/Gateway/src/generated/gateway-api.ts${NC}" log "${GREEN} - Admin SDK: SDKs/Node/Admin/src/generated/admin-api.ts${NC}" exit 0 diff --git a/SDKs/README.md b/SDKs/README.md index e08774d0c..52948944b 100755 --- a/SDKs/README.md +++ b/SDKs/README.md @@ -106,8 +106,8 @@ Each SDK includes: ## ๐Ÿ“ž Support -- **Issues**: [GitHub Issues](https://github.com/knnlabs/Conduit/issues) -- **Documentation**: [Conduit Docs](https://github.com/knnlabs/Conduit/docs) +- **Issues**: [GitHub Issues](https://github.com/nickna/Conduit/issues) +- **Documentation**: [Conduit Docs](https://github.com/nickna/Conduit/docs) - **Discord**: [Community Discord](https://discord.gg/conduit) (if available) --- diff --git a/Services/ConduitLLM.Admin/ConduitLLM.Admin.csproj b/Services/ConduitLLM.Admin/ConduitLLM.Admin.csproj index fe6fd6240..d86de144f 100644 --- a/Services/ConduitLLM.Admin/ConduitLLM.Admin.csproj +++ b/Services/ConduitLLM.Admin/ConduitLLM.Admin.csproj @@ -5,32 +5,42 @@ enable enable true + + $(NoWarn);1591;NU1510 - - - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + + + + + - - - - - - - - + + + + + + + + + - + + + + diff --git a/Services/ConduitLLM.Admin/Controllers/AdminControllerBase.cs b/Services/ConduitLLM.Admin/Controllers/AdminControllerBase.cs new file mode 100644 index 000000000..46d066e4b --- /dev/null +++ b/Services/ConduitLLM.Admin/Controllers/AdminControllerBase.cs @@ -0,0 +1,423 @@ +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Extensions; + +using MassTransit; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Admin.Controllers +{ + /// + /// Base class for Admin API controllers providing standardized error handling, + /// event publishing, and common operation patterns. + /// + /// + /// + /// This base class combines the functionality of + /// with standardized error response patterns for the Admin API. + /// + /// + /// Features: + /// + /// Fire-and-forget event publishing via MassTransit + /// Standardized error responses using + /// Async operation wrappers with automatic exception handling + /// Consistent logging patterns + /// Admin audit logging for security-sensitive operations + /// + /// + /// + public abstract class AdminControllerBase : EventPublishingControllerBase + { + /// + /// Initializes a new instance of the class. + /// + /// Optional MassTransit publish endpoint for event publishing. + /// The logger instance for the derived controller. + protected AdminControllerBase( + IPublishEndpoint? publishEndpoint, + ILogger logger) + : base(publishEndpoint, logger) + { + } + + /// + /// Initializes a new instance of the class + /// for controllers that do not require event publishing. + /// + /// The logger instance for the derived controller. + protected AdminControllerBase(ILogger logger) + : this(null, logger) + { + } + + /// + /// Executes an async operation with standardized error handling. + /// Automatically handles common exception types and returns appropriate responses. + /// + /// The type of result returned by the operation. + /// The async operation to execute. + /// Function to convert the result to an IActionResult on success. + /// Name of the operation for logging purposes. + /// Optional context data to include in log messages. + /// An appropriate IActionResult based on the operation outcome. + /// + /// This method handles the following exception types: + /// + /// - Returns 400 Bad Request + /// - Returns 400 Bad Request + /// - Returns 400 Bad Request + /// - Returns 404 Not Found + /// - Returns 403 Forbidden + /// Other exceptions - Returns 500 Internal Server Error + /// + /// + protected async Task ExecuteAsync( + Func> operation, + Func successAction, + string operationName, + object? contextData = null) + { + try + { + var result = await operation(); + LogOperationSuccess(operationName); + return successAction(result); + } + catch (Exception ex) + { + return HandleOperationException(ex, operationName, contextData); + } + } + + /// + /// Executes an async operation that directly returns an IActionResult, + /// with standardized error handling. + /// + /// The async operation that returns an IActionResult. + /// Name of the operation for logging purposes. + /// Optional context data to include in log messages. + /// The operation's IActionResult, or an error response if an exception occurs. + protected async Task ExecuteAsync( + Func> operation, + string operationName, + object? contextData = null) + { + try + { + var result = await operation(); + LogOperationSuccess(operationName); + return result; + } + catch (Exception ex) + { + return HandleOperationException(ex, operationName, contextData); + } + } + + /// + /// Executes an async operation that returns no value with standardized error handling. + /// + /// The async operation to execute. + /// The action result to return on success. + /// Name of the operation for logging purposes. + /// Optional context data to include in log messages. + /// An appropriate IActionResult based on the operation outcome. + protected async Task ExecuteAsync( + Func operation, + IActionResult successResult, + string operationName, + object? contextData = null) + { + try + { + await operation(); + if (contextData != null) + { + Logger.LogInformation("{OperationName} completed successfully with context {ContextData}", + operationName, contextData); + } + else + { + Logger.LogInformation("{OperationName} completed successfully", operationName); + } + return successResult; + } + catch (Exception ex) + { + return HandleOperationException(ex, operationName, contextData); + } + } + + /// + /// Executes an async operation that may return null with standardized error handling. + /// Returns 404 Not Found if the result is null. + /// + /// The type of result returned by the operation. + /// The async operation to execute. + /// Function to convert the non-null result to an IActionResult. + /// The type of entity being retrieved (for 404 message). + /// Optional entity ID (for 404 message). + /// Name of the operation for logging purposes. + /// An appropriate IActionResult based on the operation outcome. + protected async Task ExecuteWithNotFoundAsync( + Func> operation, + Func successAction, + string entityType, + object? entityId, + string operationName) where T : class + { + try + { + var result = await operation(); + if (result == null) + { + Logger.LogWarning("{OperationName}: {EntityType} not found with ID {EntityId}", + operationName, entityType, entityId); + return this.NotFoundEntity(entityType, entityId); + } + LogOperationSuccess(operationName, entityType, entityId); + return successAction(result); + } + catch (Exception ex) + { + return HandleOperationException(ex, operationName, new { entityType, entityId }); + } + } + + /// + /// Executes an async operation that may return null with standardized error handling. + /// Returns 404 Not Found if the result is null. Supports async success actions. + /// + /// The type of result returned by the operation. + /// The async operation to execute. + /// Async function to convert the non-null result to an IActionResult. + /// The type of entity being retrieved (for 404 message). + /// Optional entity ID (for 404 message). + /// Name of the operation for logging purposes. + /// An appropriate IActionResult based on the operation outcome. + protected async Task ExecuteWithNotFoundAsync( + Func> operation, + Func> successAction, + string entityType, + object? entityId, + string operationName) where T : class + { + try + { + var result = await operation(); + if (result == null) + { + Logger.LogWarning("{OperationName}: {EntityType} not found with ID {EntityId}", + operationName, entityType, entityId); + return this.NotFoundEntity(entityType, entityId); + } + LogOperationSuccess(operationName, entityType, entityId); + return await successAction(result); + } + catch (Exception ex) + { + return HandleOperationException(ex, operationName, new { entityType, entityId }); + } + } + + /// + /// Logs operation success at Information level for mutations (POST/PUT/PATCH/DELETE) + /// and Debug level for reads (GET/HEAD/OPTIONS). + /// + private void LogOperationSuccess(string operationName, string? entityType = null, object? entityId = null) + { + if (IsMutationRequest()) + { + if (entityType != null) + { + Logger.LogInformation("{OperationName} completed successfully for {EntityType} {EntityId}", + operationName, entityType, entityId); + } + else + { + Logger.LogInformation("{OperationName} completed successfully", operationName); + } + } + else + { + if (entityType != null) + { + Logger.LogDebug("{OperationName} completed successfully for {EntityType} {EntityId}", + operationName, entityType, entityId); + } + else + { + Logger.LogDebug("{OperationName} completed successfully", operationName); + } + } + } + + /// + /// Handles exceptions from operations with standardized logging and response formatting. + /// Uses for consistent exception-to-response mapping. + /// + /// The exception that occurred. + /// Name of the operation for logging purposes. + /// Optional context data to include in log messages. + /// An appropriate IActionResult based on the exception type. + protected IActionResult HandleOperationException( + Exception ex, + string operationName, + object? contextData = null) + { + var logMessage = contextData != null + ? $"{operationName} with context {contextData}" + : operationName; + + var mapping = ExceptionToResponseMapper.Map(ex); + + // Capture request body for mutation failures (fire-and-forget โ€” don't block error response) + _ = LogExceptionWithBodyAsync(mapping, ex, logMessage); + + // Return appropriate result type based on status code + var errorResponse = new ErrorResponseDto(mapping.ResponseMessage) { Code = mapping.ErrorCode }; + return CreateErrorResult(mapping.StatusCode, errorResponse); + } + + /// + /// Creates an appropriate IActionResult based on the HTTP status code. + /// Returns semantically correct result types (BadRequestObjectResult, NotFoundObjectResult, etc.) + /// + private IActionResult CreateErrorResult(int statusCode, ErrorResponseDto errorResponse) + { + return statusCode switch + { + 400 => new BadRequestObjectResult(errorResponse), + 404 => new NotFoundObjectResult(errorResponse), + _ => new ObjectResult(errorResponse) { StatusCode = statusCode } + }; + } + + /// + /// Logs a security-sensitive admin operation for audit purposes. + /// Captures the operation, entity context, user identity, client IP, and trace ID + /// in a structured log entry that can be filtered and queried. + /// + /// The operation performed (e.g., "Created", "Updated", "Deleted"). + /// The type of entity affected (e.g., "VirtualKey", "Provider"). + /// The identifier of the affected entity (can be null for bulk operations). + /// Optional additional detail about the operation. + protected void LogAdminAudit( + string operation, + string entityType, + object? entityId = null, + string? detail = null) + { + var clientIp = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown"; + var traceId = HttpContext?.TraceIdentifier ?? "unknown"; + var adminUser = GetAdminUserIdentity(); + + if (detail != null) + { + Logger.LogInformation( + "Admin Audit: {Operation} {EntityType} {EntityId} by {AdminUser} from {ClientIp} [TraceId: {TraceId}] - {Detail}", + operation, + entityType, + entityId ?? "N/A", + adminUser, + clientIp, + traceId, + LoggingSanitizer.S(detail)); + } + else + { + Logger.LogInformation( + "Admin Audit: {Operation} {EntityType} {EntityId} by {AdminUser} from {ClientIp} [TraceId: {TraceId}]", + operation, + entityType, + entityId ?? "N/A", + adminUser, + clientIp, + traceId); + } + } + + /// + /// Logs an admin audit event with before/after change tracking for update operations. + /// + /// The type of entity affected (e.g., "Provider", "VirtualKey"). + /// The identifier of the affected entity. + /// List of property changes with old and new values. + /// Optional additional detail about the operation. + protected void LogAdminAuditWithChanges( + string entityType, + object? entityId, + IReadOnlyList<(string Property, string? OldValue, string? NewValue)> changes, + string? detail = null) + { + if (changes.Count == 0) + return; + + var changeSummary = string.Join(", ", changes.Select(c => + $"{c.Property}: '{LoggingSanitizer.S(c.OldValue ?? "null")}' -> '{LoggingSanitizer.S(c.NewValue ?? "null")}'")); + + var fullDetail = detail != null + ? $"{detail}; Changes: [{changeSummary}]" + : $"Changes: [{changeSummary}]"; + + LogAdminAudit("Updated", entityType, entityId, fullDetail); + } + + /// + /// Logs an audit event for bulk/import operations with success and failure counts. + /// + /// The bulk operation (e.g., "ImportedCsv", "BulkCreated"). + /// The type of entity affected. + /// Number of successfully processed items. + /// Number of failed items. + protected void LogAdminAuditBulk( + string operation, + string entityType, + int successCount, + int failureCount) + { + LogAdminAudit(operation, entityType, detail: $"Success: {successCount}, Failures: {failureCount}"); + } + + /// + /// Logs an audit event for state/toggle changes on an entity. + /// + /// The type of entity affected. + /// The identifier of the affected entity (null for global settings). + /// The property being changed (e.g., "Enabled"). + /// The new value of the property. + protected void LogAdminAuditStateChange( + string entityType, + object? entityId, + string property, + object newValue) + { + LogAdminAudit("Updated", entityType, entityId, $"{property}: {newValue}"); + } + + /// + /// Gets the admin user identity string for audit logging. + /// Combines the authentication identity with any forwarded user ID from the WebAdmin. + /// + /// A string identifying the admin user (e.g., "AdminUser", "AdminUser (user:clerk_abc123)"). + private string GetAdminUserIdentity() + { + var identityName = User?.Identity?.Name ?? "Unknown"; + + // Check for forwarded user identity from WebAdmin (Clerk user ID) + var forwardedUserId = HttpContext?.Request?.Headers["X-Admin-User-Id"].FirstOrDefault(); + + if (!string.IsNullOrEmpty(forwardedUserId)) + { + return $"{identityName} (user:{LoggingSanitizer.S(forwardedUserId)})"; + } + + return identityName; + } + } +} diff --git a/Services/ConduitLLM.Admin/Controllers/AnalyticsController.cs b/Services/ConduitLLM.Admin/Controllers/AnalyticsController.cs index e50058a84..9bf84ba3d 100644 --- a/Services/ConduitLLM.Admin/Controllers/AnalyticsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/AnalyticsController.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using ConduitLLM.Admin.Extensions; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Admin.Metrics; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.DTOs.Costs; @@ -12,11 +15,10 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class AnalyticsController : ControllerBase +public class AnalyticsController : AdminControllerBase { private readonly IAnalyticsService _analyticsService; private readonly IAnalyticsMetrics? _analyticsMetrics; - private readonly ILogger _logger; /// /// Initializes a new instance of the AnalyticsController @@ -28,9 +30,9 @@ public AnalyticsController( IAnalyticsService analyticsService, ILogger logger, IAnalyticsMetrics? analyticsMetrics = null) + : base(logger) { _analyticsService = analyticsService ?? throw new ArgumentNullException(nameof(analyticsService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _analyticsMetrics = analyticsMetrics; } @@ -51,7 +53,7 @@ public AnalyticsController( [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetLogs( + public Task GetLogs( [FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] DateTime? startDate = null, @@ -60,29 +62,22 @@ public async Task GetLogs( [FromQuery] int? virtualKeyId = null, [FromQuery] int? status = null) { - try + // Validate parameters + if (page < 1) { - // Validate parameters - if (page < 1) - { - return BadRequest("Page must be greater than or equal to 1"); - } - - if (pageSize < 1 || pageSize > 100) - { - return BadRequest("Page size must be between 1 and 100"); - } - - var logs = await _analyticsService.GetLogsAsync( - page, pageSize, startDate, endDate, model, virtualKeyId, status); - - return Ok(logs); + return Task.FromResult(BadRequest("Page must be greater than or equal to 1")); } - catch (Exception ex) + + if (pageSize < 1 || pageSize > 100) { - _logger.LogError(ex, "Error getting logs"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(BadRequest("Page size must be between 1 and 100")); } + + return ExecuteAsync( + () => _analyticsService.GetLogsAsync( + page, pageSize, startDate, endDate, model, virtualKeyId, status), + Ok, + "GetLogs"); } /// @@ -94,24 +89,14 @@ public async Task GetLogs( [ProducesResponseType(typeof(LogRequestDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetLogById(int id) + public Task GetLogById(int id) { - try - { - var log = await _analyticsService.GetLogByIdAsync(id); - - if (log == null) - { - return NotFound("Log entry not found"); - } - - return Ok(log); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting log with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _analyticsService.GetLogByIdAsync(id), + Ok, + "Log entry", + id, + "GetLogById"); } /// @@ -121,18 +106,12 @@ public async Task GetLogById(int id) [HttpGet("logs/models")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetDistinctModels() + public Task GetDistinctModels() { - try - { - var models = await _analyticsService.GetDistinctModelsAsync(); - return Ok(models); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting distinct models"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _analyticsService.GetDistinctModelsAsync(), + Ok, + "GetDistinctModels"); } #endregion @@ -150,27 +129,20 @@ public async Task GetDistinctModels() [ProducesResponseType(typeof(CostDashboardDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetCostSummary( + public Task GetCostSummary( [FromQuery] string timeframe = "daily", [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null) { - try - { - // Validate timeframe - if (timeframe.ToLower() != "daily" && timeframe.ToLower() != "weekly" && timeframe.ToLower() != "monthly") - { - return BadRequest("Timeframe must be one of: daily, weekly, monthly"); - } - - var summary = await _analyticsService.GetCostSummaryAsync(timeframe, startDate, endDate); - return Ok(summary); - } - catch (Exception ex) + if (ControllerErrorExtensions.ValidateTimeframe(timeframe) is { } timeframeError) { - _logger.LogError(ex, "Error getting cost summary"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(timeframeError); } + + return ExecuteAsync( + () => _analyticsService.GetCostSummaryAsync(timeframe, startDate, endDate), + Ok, + "GetCostSummary"); } /// @@ -184,27 +156,20 @@ public async Task GetCostSummary( [ProducesResponseType(typeof(CostTrendDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetCostTrends( + public Task GetCostTrends( [FromQuery] string period = "daily", [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null) { - try + if (ControllerErrorExtensions.ValidateTimeframe(period, "Period") is { } periodError) { - // Validate period - if (period.ToLower() != "daily" && period.ToLower() != "weekly" && period.ToLower() != "monthly") - { - return BadRequest("Period must be one of: daily, weekly, monthly"); - } - - var trends = await _analyticsService.GetCostTrendsAsync(period, startDate, endDate); - return Ok(trends); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting cost trends"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(periodError); } + + return ExecuteAsync( + () => _analyticsService.GetCostTrendsAsync(period, startDate, endDate), + Ok, + "GetCostTrends"); } /// @@ -217,21 +182,15 @@ public async Task GetCostTrends( [HttpGet("costs/models")] [ProducesResponseType(typeof(ModelCostBreakdownDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelCosts( + public Task GetModelCosts( [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null, [FromQuery] int topN = 10) { - try - { - var costs = await _analyticsService.GetModelCostsAsync(startDate, endDate, topN); - return Ok(costs); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model costs"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _analyticsService.GetModelCostsAsync(startDate, endDate, topN), + Ok, + "GetModelCosts"); } /// @@ -244,21 +203,15 @@ public async Task GetModelCosts( [HttpGet("costs/virtualkeys")] [ProducesResponseType(typeof(VirtualKeyCostBreakdownDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetVirtualKeyCosts( + public Task GetVirtualKeyCosts( [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null, [FromQuery] int topN = 10) { - try - { - var costs = await _analyticsService.GetVirtualKeyCostsAsync(startDate, endDate, topN); - return Ok(costs); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting virtual key costs"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _analyticsService.GetVirtualKeyCostsAsync(startDate, endDate, topN), + Ok, + "GetVirtualKeyCosts"); } #endregion @@ -276,27 +229,20 @@ public async Task GetVirtualKeyCosts( [ProducesResponseType(typeof(AnalyticsSummaryDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAnalyticsSummary( + public Task GetAnalyticsSummary( [FromQuery] string timeframe = "daily", [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null) { - try + if (ControllerErrorExtensions.ValidateTimeframe(timeframe) is { } timeframeError) { - // Validate timeframe - if (timeframe.ToLower() != "daily" && timeframe.ToLower() != "weekly" && timeframe.ToLower() != "monthly") - { - return BadRequest("Timeframe must be one of: daily, weekly, monthly"); - } - - var summary = await _analyticsService.GetAnalyticsSummaryAsync(timeframe, startDate, endDate); - return Ok(summary); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting analytics summary"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(timeframeError); } + + return ExecuteAsync( + () => _analyticsService.GetAnalyticsSummaryAsync(timeframe, startDate, endDate), + Ok, + "GetAnalyticsSummary"); } /// @@ -309,21 +255,16 @@ public async Task GetAnalyticsSummary( [HttpGet("virtualkeys/{virtualKeyId:int}/usage")] [ProducesResponseType(typeof(UsageStatisticsDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetVirtualKeyUsage( + public Task GetVirtualKeyUsage( int virtualKeyId, [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null) { - try - { - var usage = await _analyticsService.GetVirtualKeyUsageAsync(virtualKeyId, startDate, endDate); - return Ok(usage); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting virtual key usage for ID {VirtualKeyId}", virtualKeyId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _analyticsService.GetVirtualKeyUsageAsync(virtualKeyId, startDate, endDate), + Ok, + "GetVirtualKeyUsage", + new { VirtualKeyId = virtualKeyId }); } /// @@ -339,33 +280,34 @@ public async Task GetVirtualKeyUsage( [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ExportAnalytics( + public Task ExportAnalytics( [FromQuery] string format = "csv", [FromQuery] DateTime? startDate = null, [FromQuery] DateTime? endDate = null, [FromQuery] string? model = null, [FromQuery] int? virtualKeyId = null) { - try + // Validate format + if (format.ToLower() != "csv" && format.ToLower() != "json") { - // Validate format - if (format.ToLower() != "csv" && format.ToLower() != "json") - { - return BadRequest("Format must be one of: csv, json"); - } - - var data = await _analyticsService.ExportAnalyticsAsync(format, startDate, endDate, model, virtualKeyId); - - var contentType = format.ToLower() == "csv" ? "text/csv" : "application/json"; - var fileName = $"analytics_{DateTime.UtcNow:yyyyMMdd_HHmmss}.{format.ToLower()}"; - - return File(data, contentType, fileName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error exporting analytics"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(BadRequest("Format must be one of: csv, json")); } + + return ExecuteAsync( + async () => + { + using var activity = AdminRequestMetrics.StartCsvActivity("export", "analytics"); + var data = await _analyticsService.ExportAnalyticsAsync(format, startDate, endDate, model, virtualKeyId); + + var contentType = format.ToLower() == "csv" ? "text/csv" : "application/json"; + var fileName = $"analytics_{DateTime.UtcNow:yyyyMMdd_HHmmss}.{format.ToLower()}"; + + LogAdminAudit("Exported", "AnalyticsData", detail: $"Format: {format}, StartDate: {startDate:O}, EndDate: {endDate:O}"); + AdminOperationsMetricsService.RecordCsvOperation("export", "analytics", "success"); + return (IActionResult)File(data, contentType, fileName); + }, + result => result, + "ExportAnalytics"); } #endregion @@ -416,21 +358,20 @@ public IActionResult GetOperationMetrics() [HttpPost("cache/invalidate")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult InvalidateCache([FromQuery] string reason = "Manual invalidation") + public Task InvalidateCache([FromQuery] string reason = "Manual invalidation") { - try - { - // TODO: Implement cache invalidation logic - _analyticsMetrics?.RecordCacheInvalidation(reason, 0); - _logger.LogInformation("Cache invalidation requested: {Reason}", reason); - return Ok(new { message = "Cache invalidation initiated", reason }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error invalidating cache"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + // TODO: Implement cache invalidation logic + _analyticsMetrics?.RecordCacheInvalidation(reason, 0); + await Task.CompletedTask; + LogAdminAudit("Invalidated", "AnalyticsCache", detail: $"Reason: {reason}"); + return new { message = "Cache invalidation initiated", reason }; + }, + result => Ok(result), + "InvalidateCache"); } #endregion -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Controllers/AuthController.cs b/Services/ConduitLLM.Admin/Controllers/AuthController.cs index 28693aabf..937afbbf5 100644 --- a/Services/ConduitLLM.Admin/Controllers/AuthController.cs +++ b/Services/ConduitLLM.Admin/Controllers/AuthController.cs @@ -10,10 +10,9 @@ namespace ConduitLLM.Admin.Controllers /// [ApiController] [Route("api/admin/auth")] - public class AuthController : ControllerBase + public class AuthController : AdminControllerBase { private readonly IEphemeralMasterKeyService _ephemeralMasterKeyService; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -23,9 +22,9 @@ public class AuthController : ControllerBase public AuthController( IEphemeralMasterKeyService ephemeralMasterKeyService, ILogger logger) + : base(logger) { _ephemeralMasterKeyService = ephemeralMasterKeyService ?? throw new ArgumentNullException(nameof(ephemeralMasterKeyService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -40,26 +39,20 @@ public AuthController( [ProducesResponseType(typeof(EphemeralMasterKeyResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> GenerateEphemeralMasterKey() + public Task GenerateEphemeralMasterKey() { - try - { - // Create ephemeral master key - var response = await _ephemeralMasterKeyService.CreateEphemeralMasterKeyAsync(); + return ExecuteAsync( + async () => + { + // Create ephemeral master key + var response = await _ephemeralMasterKeyService.CreateEphemeralMasterKeyAsync(); - _logger.LogInformation("Generated ephemeral master key"); + LogAdminAudit("Generated", "EphemeralMasterKey", detail: $"TTL: {response.ExpiresInSeconds}s"); - return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to generate ephemeral master key"); - return StatusCode(500, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "Failed to generate ephemeral master key" - }); - } + return response; + }, + Ok, + "GenerateEphemeralMasterKey"); } } } diff --git a/Services/ConduitLLM.Admin/Controllers/BatchSpendingController.cs b/Services/ConduitLLM.Admin/Controllers/BatchSpendingController.cs index b99d9dca0..7966dcc85 100644 --- a/Services/ConduitLLM.Admin/Controllers/BatchSpendingController.cs +++ b/Services/ConduitLLM.Admin/Controllers/BatchSpendingController.cs @@ -2,27 +2,27 @@ using Microsoft.AspNetCore.Mvc; using MassTransit; using ConduitLLM.Configuration.Events; +using ConduitLLM.Core.Extensions; namespace ConduitLLM.Admin.Controllers { /// /// Administrative controller for managing batch spending operations. - /// + /// /// This controller provides endpoints for administrators to: /// - Trigger immediate flushing of pending batch spend updates /// - Monitor batch spending service status and statistics /// - Perform operational maintenance on the spending system - /// + /// /// All endpoints require master key authentication for security. /// Operations are performed via event-driven architecture for proper decoupling. /// [ApiController] [Route("api/batch-spending")] [Authorize(Policy = "MasterKeyPolicy")] - public class BatchSpendingController : ControllerBase + public class BatchSpendingController : AdminControllerBase { private readonly IPublishEndpoint _publishEndpoint; - private readonly ILogger _logger; /// /// Initializes a new instance of the BatchSpendingController. @@ -32,23 +32,23 @@ public class BatchSpendingController : ControllerBase public BatchSpendingController( IPublishEndpoint publishEndpoint, ILogger logger) + : base(publishEndpoint, logger) { _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Triggers immediate flushing of all pending batch spend updates. - /// + /// /// This endpoint publishes a BatchSpendFlushRequestedEvent which is consumed by the Gateway API /// to immediately process all queued spending charges instead of waiting for the scheduled /// batch interval. This is essential for: - /// + /// /// - Integration testing (deterministic billing verification) /// - Administrative operations (manual reconciliation) /// - Maintenance scenarios (pre-deployment charge processing) /// - Emergency operations (immediate financial updates) - /// + /// /// The operation is asynchronous and event-driven for proper architectural decoupling. /// /// Optional reason for the flush operation (for audit trail) @@ -60,136 +60,114 @@ public BatchSpendingController( [ProducesResponseType(typeof(object), 202)] [ProducesResponseType(400)] [ProducesResponseType(500)] - public async Task FlushPendingUpdates( + public Task FlushPendingUpdates( [FromQuery] string? reason = null, [FromQuery] FlushPriority priority = FlushPriority.Normal, [FromQuery] int? timeoutSeconds = null, [FromQuery] bool includeStatistics = true) { - try - { - // Generate unique request ID for tracking - var requestId = Guid.NewGuid().ToString(); - - _logger.LogInformation( - "Admin requesting batch spend flush - RequestId: {RequestId}, Reason: {Reason}, Priority: {Priority}", - requestId, reason ?? "Administrative operation", priority); - - // Validate timeout parameter - if (timeoutSeconds.HasValue && (timeoutSeconds.Value < 1 || timeoutSeconds.Value > 300)) + return ExecuteAsync( + async () => { - return BadRequest(new + // Generate unique request ID for tracking + var requestId = Guid.NewGuid().ToString(); + + Logger.LogInformation( + "Admin requesting batch spend flush - RequestId: {RequestId}, Reason: {Reason}, Priority: {Priority}", + requestId, reason ?? "Administrative operation", priority); + + // Validate timeout parameter + if (timeoutSeconds.HasValue && (timeoutSeconds.Value < 1 || timeoutSeconds.Value > 300)) { - success = false, - error = "Timeout must be between 1 and 300 seconds", - requestId = requestId - }); - } + throw new ArgumentException("Timeout must be between 1 and 300 seconds"); + } - // Create and publish flush request event - var flushEvent = new BatchSpendFlushRequestedEvent - { - RequestId = requestId, - RequestedBy = "Admin", - RequestedAt = DateTime.UtcNow, - Reason = reason ?? "Administrative flush operation", - Source = "Admin API", - Priority = priority, - TimeoutSeconds = timeoutSeconds, - IncludeStatistics = includeStatistics - }; + // Create and publish flush request event + var flushEvent = new BatchSpendFlushRequestedEvent + { + RequestId = requestId, + RequestedBy = "Admin", + RequestedAt = DateTime.UtcNow, + Reason = reason ?? "Administrative flush operation", + Source = "Admin API", + Priority = priority, + TimeoutSeconds = timeoutSeconds, + IncludeStatistics = includeStatistics + }; - // Publish event to Gateway API for processing - await _publishEndpoint.Publish(flushEvent); + // Publish event to Gateway API for processing + await _publishEndpoint.Publish(flushEvent); - _logger.LogInformation( - "Published BatchSpendFlushRequestedEvent - RequestId: {RequestId}", requestId); + LogAdminAudit("Flushed", "BatchSpending", + detail: $"RequestId: {requestId}, Priority: {priority}, Reason: {LoggingSanitizer.S(reason ?? "Administrative flush operation")}"); - // Return accepted response with tracking information - return Accepted(new - { - success = true, - message = "Batch spend flush request submitted successfully", - requestId = requestId, - requestedAt = flushEvent.RequestedAt, - priority = priority.ToString(), - estimatedProcessingTime = timeoutSeconds.HasValue - ? $"Up to {timeoutSeconds} seconds" - : "Based on service configuration", - note = "This is an asynchronous operation. Monitor logs for completion status." - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to publish batch spend flush request: {ErrorMessage}", ex.Message); - - return StatusCode(500, new - { - success = false, - error = "Failed to submit batch spend flush request", - message = ex.Message, - timestamp = DateTime.UtcNow - }); - } + // Return accepted response with tracking information + return (object)new + { + success = true, + message = "Batch spend flush request submitted successfully", + requestId = requestId, + requestedAt = flushEvent.RequestedAt, + priority = priority.ToString(), + estimatedProcessingTime = timeoutSeconds.HasValue + ? $"Up to {timeoutSeconds} seconds" + : "Based on service configuration", + note = "This is an asynchronous operation. Monitor logs for completion status." + }; + }, + result => Accepted(result), + "FlushPendingUpdates"); } /// /// Gets information about the batch spending system status. - /// + /// /// This endpoint provides operational visibility into: /// - Event publishing capability /// - System readiness for flush operations /// - Configuration details - /// + /// /// Note: This endpoint checks the Admin API's ability to publish events, /// not the Gateway API's batch spending service status (which is internal). /// /// System status and configuration information [HttpGet("status")] [ProducesResponseType(typeof(object), 200)] - public IActionResult GetStatus() + public Task GetStatus() { - try - { - var isEventBusAvailable = _publishEndpoint != null; - - return Ok(new + return ExecuteAsync( + () => { - success = true, - adminApiStatus = "healthy", - eventBusAvailable = isEventBusAvailable, - canPublishFlushRequests = isEventBusAvailable, - supportedOperations = new[] - { - "flush - Trigger immediate batch spend processing", - "status - Get system status information" - }, - architecture = new + var isEventBusAvailable = IsEventPublishingEnabled; + + return Task.FromResult(new { - pattern = "Event-driven with MassTransit", - adminRole = "Publishes BatchSpendFlushRequestedEvent", - coreRole = "Consumes events and performs actual flush operations", - decoupling = "Admin and Gateway APIs communicate via events only" - }, - timestamp = DateTime.UtcNow - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting batch spending status: {ErrorMessage}", ex.Message); - - return StatusCode(500, new - { - success = false, - error = "Failed to get system status", - message = ex.Message - }); - } + success = true, + adminApiStatus = "healthy", + eventBusAvailable = isEventBusAvailable, + canPublishFlushRequests = isEventBusAvailable, + supportedOperations = new[] + { + "flush - Trigger immediate batch spend processing", + "status - Get system status information" + }, + architecture = new + { + pattern = "Event-driven with MassTransit", + adminRole = "Publishes BatchSpendFlushRequestedEvent", + coreRole = "Consumes events and performs actual flush operations", + decoupling = "Admin and Gateway APIs communicate via events only" + }, + timestamp = DateTime.UtcNow + }); + }, + Ok, + "GetStatus"); } /// /// Gets operational information about the batch spending flush capability. - /// + /// /// This endpoint provides documentation and operational guidance for administrators /// without exposing internal Gateway API details. /// @@ -202,7 +180,7 @@ public IActionResult GetInformation() { service = "Batch Spending Administration", description = "Administrative interface for managing batch spend update operations", - + endpoints = new { flush = new @@ -232,7 +210,7 @@ public IActionResult GetInformation() description = "Gets system status and event publishing capability" } }, - + architecture = new { pattern = "Event-driven architecture with MassTransit", @@ -240,7 +218,7 @@ public IActionResult GetInformation() reliability = "Asynchronous processing with error handling and retry policies", monitoring = "Full audit trail via structured logging" }, - + operationalNotes = new[] { "All operations are asynchronous and event-driven", @@ -249,9 +227,9 @@ public IActionResult GetInformation() "High priority requests are processed with elevated logging", "Failed operations include detailed error information in logs" }, - + timestamp = DateTime.UtcNow }); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Controllers/BillingAuditController.cs b/Services/ConduitLLM.Admin/Controllers/BillingAuditController.cs index 28da1a986..49b37fcf8 100644 --- a/Services/ConduitLLM.Admin/Controllers/BillingAuditController.cs +++ b/Services/ConduitLLM.Admin/Controllers/BillingAuditController.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using ConduitLLM.Admin.DTOs; +using ConduitLLM.Admin.Extensions; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -15,11 +16,10 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/audit/billing")] [Authorize] - public class BillingAuditController : ControllerBase + public class BillingAuditController : AdminControllerBase { private readonly IBillingAuditService _billingAuditService; - private readonly ILogger _logger; - + // Metrics for billing audit API operations private static readonly Counter BillingAuditQueries = Prometheus.Metrics .CreateCounter("conduit_admin_billing_audit_queries_total", "Total billing audit queries", @@ -27,7 +27,7 @@ public class BillingAuditController : ControllerBase { LabelNames = new[] { "endpoint", "status" } }); - + private static readonly Histogram BillingAuditQueryDuration = Prometheus.Metrics .CreateHistogram("conduit_admin_billing_audit_query_duration_seconds", "Billing audit query duration", new HistogramConfiguration @@ -35,14 +35,14 @@ public class BillingAuditController : ControllerBase LabelNames = new[] { "endpoint" }, Buckets = Histogram.ExponentialBuckets(0.01, 2, 10) // 10ms to ~10s }); - + private static readonly Counter BillingAuditExports = Prometheus.Metrics .CreateCounter("conduit_admin_billing_audit_exports_total", "Total billing audit exports", new CounterConfiguration { LabelNames = new[] { "format", "status" } }); - + private static readonly Gauge BillingAnomaliesDetected = Prometheus.Metrics .CreateGauge("conduit_admin_billing_anomalies_detected", "Number of billing anomalies detected", new GaugeConfiguration @@ -56,9 +56,9 @@ public class BillingAuditController : ControllerBase public BillingAuditController( IBillingAuditService billingAuditService, ILogger logger) + : base(logger) { _billingAuditService = billingAuditService ?? throw new ArgumentNullException(nameof(billingAuditService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -69,47 +69,47 @@ public BillingAuditController( [HttpPost("query")] [ProducesResponseType(typeof(BillingAuditResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task QueryAuditEvents([FromBody] BillingAuditQueryRequest request) + public Task QueryAuditEvents([FromBody] BillingAuditQueryRequest request) { - if (request.From > request.To) + if (ControllerErrorExtensions.ValidateDateRange(request.From, request.To) is { } dateError) { - return BadRequest("From date must be before or equal to To date"); + return Task.FromResult(dateError); } if (request.PageSize > 1000) { - return BadRequest("Page size cannot exceed 1000"); + return Task.FromResult(BadRequest("Page size cannot exceed 1000")); } - try - { - using var timer = BillingAuditQueryDuration.WithLabels("query").NewTimer(); - - var (events, totalCount) = await _billingAuditService.GetAuditEventsAsync( - request.From, - request.To, - request.EventType, - request.VirtualKeyId, - request.PageNumber, - request.PageSize); - - var response = new BillingAuditResponse + return ExecuteAsync( + async () => { - Events = events.Select(e => MapToDto(e)).ToList(), - TotalCount = totalCount, - PageNumber = request.PageNumber, - PageSize = request.PageSize - }; - - BillingAuditQueries.WithLabels("query", "success").Inc(); - return Ok(response); - } - catch (Exception ex) - { - BillingAuditQueries.WithLabels("query", "error").Inc(); - _logger.LogError(ex, "Error querying billing audit events"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while querying audit events"); - } + using var timer = BillingAuditQueryDuration.WithLabels("query").NewTimer(); + + var (events, totalCount) = await _billingAuditService.GetAuditEventsAsync( + request.From, + request.To, + request.EventType, + request.VirtualKeyId, + request.PageNumber, + request.PageSize); + + var response = new BillingAuditResponse + { + Events = events.Select(e => MapToDto(e)).ToList(), + TotalCount = totalCount, + PageNumber = request.PageNumber, + PageSize = request.PageSize + }; + + Logger.LogDebug("Billing audit query returned {TotalCount} events (page {Page}/{PageSize})", + totalCount, request.PageNumber, request.PageSize); + + BillingAuditQueries.WithLabels("query", "success").Inc(); + return response; + }, + Ok, + "QueryAuditEvents"); } /// @@ -122,31 +122,28 @@ public async Task QueryAuditEvents([FromBody] BillingAuditQueryRe [HttpGet("summary")] [ProducesResponseType(typeof(BillingAuditSummary), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task GetSummary( + public Task GetSummary( [FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] int? virtualKeyId = null) { - if (from > to) + if (ControllerErrorExtensions.ValidateDateRange(from, to) is { } dateError) { - return BadRequest("From date must be before or equal to To date"); + return Task.FromResult(dateError); } - try - { - using var timer = BillingAuditQueryDuration.WithLabels("summary").NewTimer(); - - var summary = await _billingAuditService.GetAuditSummaryAsync(from, to, virtualKeyId); - - BillingAuditQueries.WithLabels("summary", "success").Inc(); - return Ok(summary); - } - catch (Exception ex) - { - BillingAuditQueries.WithLabels("summary", "error").Inc(); - _logger.LogError(ex, "Error getting billing audit summary"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while getting audit summary"); - } + return ExecuteAsync( + async () => + { + using var timer = BillingAuditQueryDuration.WithLabels("summary").NewTimer(); + + var summary = await _billingAuditService.GetAuditSummaryAsync(from, to, virtualKeyId); + + BillingAuditQueries.WithLabels("summary", "success").Inc(); + return summary; + }, + Ok, + "GetSummary"); } /// @@ -158,37 +155,34 @@ public async Task GetSummary( [HttpGet("anomalies")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task DetectAnomalies( + public Task DetectAnomalies( [FromQuery] DateTime from, [FromQuery] DateTime to) { - if (from > to) + if (ControllerErrorExtensions.ValidateDateRange(from, to) is { } dateError) { - return BadRequest("From date must be before or equal to To date"); + return Task.FromResult(dateError); } - try - { - using var timer = BillingAuditQueryDuration.WithLabels("anomalies").NewTimer(); - - var anomalies = await _billingAuditService.DetectAnomaliesAsync(from, to); - - // Update anomaly gauge metrics - var anomalyGroups = anomalies.GroupBy(a => a.Severity ?? "unknown"); - foreach (var group in anomalyGroups) + return ExecuteAsync( + async () => { - BillingAnomaliesDetected.WithLabels(group.Key).Set(group.Count()); - } - - BillingAuditQueries.WithLabels("anomalies", "success").Inc(); - return Ok(anomalies); - } - catch (Exception ex) - { - BillingAuditQueries.WithLabels("anomalies", "error").Inc(); - _logger.LogError(ex, "Error detecting billing anomalies"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while detecting anomalies"); - } + using var timer = BillingAuditQueryDuration.WithLabels("anomalies").NewTimer(); + + var anomalies = await _billingAuditService.DetectAnomaliesAsync(from, to); + + // Update anomaly gauge metrics + var anomalyGroups = anomalies.GroupBy(a => a.Severity ?? "unknown"); + foreach (var group in anomalyGroups) + { + BillingAnomaliesDetected.WithLabels(group.Key).Set(group.Count()); + } + + BillingAuditQueries.WithLabels("anomalies", "success").Inc(); + return anomalies; + }, + Ok, + "DetectAnomalies"); } /// @@ -200,30 +194,27 @@ public async Task DetectAnomalies( [HttpGet("revenue-loss")] [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task GetRevenueLoss( + public Task GetRevenueLoss( [FromQuery] DateTime from, [FromQuery] DateTime to) { - if (from > to) + if (ControllerErrorExtensions.ValidateDateRange(from, to) is { } dateError) { - return BadRequest("From date must be before or equal to To date"); + return Task.FromResult(dateError); } - try - { - using var timer = BillingAuditQueryDuration.WithLabels("revenue-loss").NewTimer(); - - var loss = await _billingAuditService.GetPotentialRevenueLossAsync(from, to); - - BillingAuditQueries.WithLabels("revenue-loss", "success").Inc(); - return Ok(new { potentialRevenueLoss = loss, currency = "USD" }); - } - catch (Exception ex) - { - BillingAuditQueries.WithLabels("revenue-loss", "error").Inc(); - _logger.LogError(ex, "Error calculating revenue loss"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while calculating revenue loss"); - } + return ExecuteAsync( + async () => + { + using var timer = BillingAuditQueryDuration.WithLabels("revenue-loss").NewTimer(); + + var loss = await _billingAuditService.GetPotentialRevenueLossAsync(from, to); + + BillingAuditQueries.WithLabels("revenue-loss", "success").Inc(); + return new { potentialRevenueLoss = loss, currency = "USD" }; + }, + result => Ok(result), + "GetRevenueLoss"); } /// @@ -234,51 +225,51 @@ public async Task GetRevenueLoss( [HttpPost("export")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task ExportAuditEvents([FromBody] BillingAuditExportRequest request) + public Task ExportAuditEvents([FromBody] BillingAuditExportRequest request) { - if (request.From > request.To) + if (ControllerErrorExtensions.ValidateDateRange(request.From, request.To) is { } dateError) { - return BadRequest("From date must be before or equal to To date"); + return Task.FromResult(dateError); } - try - { - using var timer = BillingAuditQueryDuration.WithLabels("export").NewTimer(); - - // Get all events for the period (no pagination for export) - var (events, _) = await _billingAuditService.GetAuditEventsAsync( - request.From, - request.To, - request.EventType, - request.VirtualKeyId, - pageNumber: 1, - pageSize: int.MaxValue); - - switch (request.Format) + return ExecuteAsync( + async () => { - case ExportFormat.Json: - BillingAuditExports.WithLabels("json", "success").Inc(); - return ExportAsJson(events); - - case ExportFormat.Csv: - BillingAuditExports.WithLabels("csv", "success").Inc(); - return ExportAsCsv(events); - - case ExportFormat.Excel: - BillingAuditExports.WithLabels("excel", "not_implemented").Inc(); - return BadRequest("Excel export not yet implemented"); - - default: - BillingAuditExports.WithLabels(request.Format.ToString(), "unsupported").Inc(); - return BadRequest($"Unsupported export format: {request.Format}"); - } - } - catch (Exception ex) - { - BillingAuditExports.WithLabels(request.Format.ToString(), "error").Inc(); - _logger.LogError(ex, "Error exporting billing audit events"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while exporting audit events"); - } + using var timer = BillingAuditQueryDuration.WithLabels("export").NewTimer(); + + // Get all events for the period (no pagination for export) + var (events, _) = await _billingAuditService.GetAuditEventsAsync( + request.From, + request.To, + request.EventType, + request.VirtualKeyId, + pageNumber: 1, + pageSize: int.MaxValue); + + Logger.LogInformation("Exporting {EventCount} billing audit events as {Format} for period {From:O} to {To:O}", + events.Count, request.Format, request.From, request.To); + + switch (request.Format) + { + case ExportFormat.Json: + BillingAuditExports.WithLabels("json", "success").Inc(); + return ExportAsJson(events); + + case ExportFormat.Csv: + BillingAuditExports.WithLabels("csv", "success").Inc(); + return ExportAsCsv(events); + + case ExportFormat.Excel: + BillingAuditExports.WithLabels("excel", "not_implemented").Inc(); + return (IActionResult)BadRequest("Excel export not yet implemented"); + + default: + BillingAuditExports.WithLabels(request.Format.ToString(), "unsupported").Inc(); + return (IActionResult)BadRequest($"Unsupported export format: {request.Format}"); + } + }, + result => result, + "ExportAuditEvents"); } /// @@ -332,7 +323,7 @@ private BillingAuditEventDto MapToDto(BillingAuditEvent entity) catch (JsonException) { // Log but don't fail - _logger.LogWarning("Failed to parse usage JSON for audit event {Id}", entity.Id); + Logger.LogWarning("Failed to parse usage JSON for audit event {Id}", entity.Id); } } @@ -346,7 +337,7 @@ private BillingAuditEventDto MapToDto(BillingAuditEvent entity) } catch (JsonException) { - _logger.LogWarning("Failed to parse metadata JSON for audit event {Id}", entity.Id); + Logger.LogWarning("Failed to parse metadata JSON for audit event {Id}", entity.Id); } } @@ -409,4 +400,4 @@ private string GetEventTypeDescription(BillingAuditEventType eventType) }; } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Controllers/ConfigurationController.cs b/Services/ConduitLLM.Admin/Controllers/ConfigurationController.cs index 47ce2ccd4..c469253ac 100644 --- a/Services/ConduitLLM.Admin/Controllers/ConfigurationController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ConfigurationController.cs @@ -1,4 +1,5 @@ using ConduitLLM.Configuration; +using ConduitLLM.Core.Extensions; using Microsoft.AspNetCore.Authorization; using ConduitLLM.Configuration.DTOs; using Microsoft.AspNetCore.Mvc; @@ -15,13 +16,11 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/config")] [Authorize(Policy = "MasterKeyPolicy")] - public class ConfigurationController : ControllerBase + public class ConfigurationController : AdminControllerBase { private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; private readonly IMemoryCache _cache; private readonly IConfiguration _configuration; - private readonly ICacheManagementService? _cacheManagementService; private readonly ILLMCacheManagementService _llmCacheManagementService; /// @@ -31,21 +30,18 @@ public class ConfigurationController : ControllerBase /// Logger instance. /// Memory cache. /// Application configuration. - /// Service for cache maintenance operations (optional - required only for general cache endpoints). /// Service for LLM cache toggle operations. public ConfigurationController( IDbContextFactory dbContextFactory, ILogger logger, IMemoryCache cache, IConfiguration configuration, - ILLMCacheManagementService llmCacheManagementService, - ICacheManagementService? cacheManagementService = null) + ILLMCacheManagementService llmCacheManagementService) + : base(logger) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _cacheManagementService = cacheManagementService; // Optional - may be null _llmCacheManagementService = llmCacheManagementService ?? throw new ArgumentNullException(nameof(llmCacheManagementService)); } @@ -55,308 +51,66 @@ public ConfigurationController( /// Cancellation token. /// Routing configuration data. [HttpGet("routing")] - public async Task GetRoutingConfig(CancellationToken cancellationToken = default) + public Task GetRoutingConfig(CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return ExecuteAsync( + async () => + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - // Get model-to-provider mappings - var modelMappings = await dbContext.ModelProviderMappings - .Include(m => m.Provider) - .Select(m => new + // Get model-to-provider mappings + var modelMappings = await dbContext.ModelProviderMappings + .Include(m => m.Provider) + .Select(m => new + { + Id = m.Id, + ModelAlias = m.ModelAlias, + ProviderModelId = m.ProviderModelId, + IsEnabled = m.IsEnabled, + Provider = new + { + Id = m.Provider.Id, + Name = m.Provider.ProviderName, + Type = m.Provider.ProviderType, + IsEnabled = m.Provider.IsEnabled + } + }) + .ToListAsync(cancellationToken); + + // Get load balancing configuration + var loadBalancers = new List { - Id = m.Id, - ModelAlias = m.ModelAlias, - ProviderModelId = m.ProviderModelId, - IsEnabled = m.IsEnabled, - Provider = new + new { - Id = m.Provider.Id, - Name = m.Provider.ProviderName, - Type = m.Provider.ProviderType, - IsEnabled = m.Provider.IsEnabled + Id = "primary", + Name = "Primary Load Balancer", + Algorithm = _configuration["LoadBalancing:Algorithm"] ?? "round-robin", + HealthCheckInterval = 30, + FailoverThreshold = 3, + Endpoints = await GetProviderEndpoints(dbContext, cancellationToken) } - }) - .ToListAsync(cancellationToken); - - // Get load balancing configuration - var loadBalancers = new List - { - new - { - Id = "primary", - Name = "Primary Load Balancer", - Algorithm = _configuration["LoadBalancing:Algorithm"] ?? "round-robin", - HealthCheckInterval = 30, - FailoverThreshold = 3, - Endpoints = await GetProviderEndpoints(dbContext, cancellationToken) - } - }; - + }; - // Get routing statistics - var routingStats = await GetRoutingStatistics(dbContext, cancellationToken); + // Get routing statistics + var routingStats = await GetRoutingStatistics(dbContext, cancellationToken); - return Ok(new - { - Timestamp = DateTime.UtcNow, - RoutingRules = modelMappings, - LoadBalancers = loadBalancers, - Statistics = routingStats, - Configuration = new + return (object)new { - EnableFailover = _configuration.GetValue("Routing:EnableFailover", true), - EnableLoadBalancing = _configuration.GetValue("Routing:EnableLoadBalancing", true), - RequestTimeout = _configuration.GetValue("Routing:RequestTimeoutSeconds", 30), - CircuitBreakerThreshold = _configuration.GetValue("Routing:CircuitBreakerThreshold", 5) - } - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve routing configuration"); - return StatusCode(500, new { error = "Failed to retrieve routing configuration", message = ex.Message }); - } - } - - /// - /// Gets caching configuration and statistics. - /// - /// Cancellation token. - /// Caching configuration data. - [HttpGet("caching")] - public async Task GetCachingConfig(CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - var configuration = await _cacheManagementService.GetConfigurationAsync(cancellationToken); - return Ok(configuration); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve caching configuration"); - return StatusCode(500, new { error = "Failed to retrieve caching configuration", message = ex.Message }); - } - } - - - /// - /// Updates caching configuration. - /// - /// Updated caching configuration. - /// Cancellation token. - /// Success response. - [HttpPut("caching")] - public async Task UpdateCachingConfig([FromBody] UpdateCacheConfigDto config, CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - await _cacheManagementService.UpdateConfigurationAsync(config, cancellationToken); - return Ok(new { message = "Caching configuration updated successfully" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update caching configuration"); - return StatusCode(500, new { error = "Failed to update caching configuration", message = ex.Message }); - } - } - - /// - /// Clears specific cache by ID. - /// - /// Cache policy ID. - /// Cancellation token. - /// Success response. - [HttpPost("caching/{cacheId}/clear")] - public async Task ClearCache(string cacheId, CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - await _cacheManagementService.ClearCacheAsync(cacheId, cancellationToken); - return Ok(new { message = $"Cache '{cacheId}' cleared successfully" }); - } - catch (ArgumentException ex) - { - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to clear cache {CacheId}", cacheId); - return StatusCode(500, new { error = "Failed to clear cache", message = ex.Message }); - } - } - - /// - /// Gets cache statistics for all regions or a specific region. - /// - /// Optional region ID. - /// Cancellation token. - /// Cache statistics. - [HttpGet("caching/statistics")] - public async Task GetCacheStatistics([FromQuery] string? regionId = null, CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - var statistics = await _cacheManagementService.GetStatisticsAsync(regionId, cancellationToken); - return Ok(statistics); - } - catch (ArgumentException ex) - { - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get cache statistics"); - return StatusCode(500, new { error = "Failed to get cache statistics", message = ex.Message }); - } - } - - /// - /// Lists all cache regions. - /// - /// Cancellation token. - /// List of cache regions. - [HttpGet("caching/regions")] - public async Task GetCacheRegions(CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - var configuration = await _cacheManagementService.GetConfigurationAsync(cancellationToken); - return Ok(new - { - Regions = configuration.CacheRegions, - Timestamp = DateTime.UtcNow - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get cache regions"); - return StatusCode(500, new { error = "Failed to get cache regions", message = ex.Message }); - } - } - - /// - /// Gets entries from a specific cache region. - /// - /// Region ID. - /// Number of entries to skip. - /// Number of entries to return. - /// Cancellation token. - /// Cache entries. - [HttpGet("caching/{regionId}/entries")] - public async Task GetCacheEntries(string regionId, [FromQuery] int skip = 0, [FromQuery] int take = 100, CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - if (take > 1000) - { - return BadRequest(new ErrorResponseDto("Cannot retrieve more than 1000 entries at once")); - } - - var entries = await _cacheManagementService.GetEntriesAsync(regionId, skip, take, cancellationToken); - return Ok(entries); - } - catch (ArgumentException ex) - { - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get cache entries for region {RegionId}", regionId); - return StatusCode(500, new { error = "Failed to get cache entries", message = ex.Message }); - } - } - - /// - /// Forces a refresh of cache entries in a region. - /// - /// Region ID. - /// Optional specific key to refresh. - /// Cancellation token. - /// Success response. - [HttpPost("caching/{regionId}/refresh")] - public async Task RefreshCache(string regionId, [FromQuery] string? key = null, CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - await _cacheManagementService.RefreshCacheAsync(regionId, key, cancellationToken); - var message = string.IsNullOrEmpty(key) - ? $"Cache region '{regionId}' refreshed successfully" - : $"Cache key '{key}' in region '{regionId}' refreshed successfully"; - return Ok(new { message }); - } - catch (ArgumentException ex) - { - return BadRequest(new { error = ex.Message }); - } - catch (KeyNotFoundException ex) - { - return NotFound(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh cache for region {RegionId}", regionId); - return StatusCode(500, new { error = "Failed to refresh cache", message = ex.Message }); - } - } - - /// - /// Updates the policy for a specific cache region. - /// - /// Region ID. - /// Policy update details. - /// Cancellation token. - /// Success response. - [HttpPut("caching/{regionId}/policy")] - public async Task UpdateCachePolicy(string regionId, [FromBody] UpdateCachePolicyDto policyUpdate, CancellationToken cancellationToken = default) - { - try - { - if (_cacheManagementService == null) - { - return StatusCode(501, new { error = "General cache management service not implemented", message = "This endpoint requires cache infrastructure services that are not currently registered." }); - } - await _cacheManagementService.UpdatePolicyAsync(regionId, policyUpdate, cancellationToken); - return Ok(new { message = $"Cache policy for region '{regionId}' updated successfully" }); - } - catch (ArgumentException ex) - { - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update cache policy for region {RegionId}", regionId); - return StatusCode(500, new { error = "Failed to update cache policy", message = ex.Message }); - } + Timestamp = DateTime.UtcNow, + RoutingRules = modelMappings, + LoadBalancers = loadBalancers, + Statistics = routingStats, + Configuration = new + { + EnableFailover = _configuration.GetValue("Routing:EnableFailover", true), + EnableLoadBalancing = _configuration.GetValue("Routing:EnableLoadBalancing", true), + RequestTimeout = _configuration.GetValue("Routing:RequestTimeoutSeconds", 30), + CircuitBreakerThreshold = _configuration.GetValue("Routing:CircuitBreakerThreshold", 5) + } + }; + }, + Ok, + "GetRoutingConfig"); } private async Task> GetProviderEndpoints(ConduitDbContext dbContext, CancellationToken cancellationToken) @@ -412,18 +166,12 @@ private async Task GetRoutingStatistics(ConduitDbContext dbContext, Canc /// LLM cache control status. [HttpGet("caching/llm-status")] [ProducesResponseType(typeof(LLMCacheControlDto), 200)] - public async Task GetLLMCacheStatus(CancellationToken cancellationToken = default) + public Task GetLLMCacheStatus(CancellationToken cancellationToken = default) { - try - { - var status = await _llmCacheManagementService.GetLLMCacheStatusAsync(cancellationToken); - return Ok(status); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get LLM cache status"); - return StatusCode(500, new { error = "Failed to get LLM cache status", message = ex.Message }); - } + return ExecuteAsync( + () => _llmCacheManagementService.GetLLMCacheStatusAsync(cancellationToken), + Ok, + "GetLLMCacheStatus"); } /// @@ -434,24 +182,22 @@ public async Task GetLLMCacheStatus(CancellationToken cancellatio /// Updated LLM cache control status. [HttpPost("caching/llm-toggle")] [ProducesResponseType(typeof(LLMCacheControlDto), 200)] - public async Task ToggleLLMCache([FromBody] ToggleLLMCacheRequest request, CancellationToken cancellationToken = default) + public Task ToggleLLMCache([FromBody] ToggleLLMCacheRequest request, CancellationToken cancellationToken = default) { - try - { - var userName = User?.Identity?.Name ?? "Unknown"; - var result = await _llmCacheManagementService.ToggleLLMCacheAsync( - request.Enabled, - userName, - request.Reason, - cancellationToken); - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to toggle LLM cache"); - return StatusCode(500, new { error = "Failed to toggle LLM cache", message = ex.Message }); - } + return ExecuteAsync( + async () => + { + var userName = User?.Identity?.Name ?? "Unknown"; + var result = await _llmCacheManagementService.ToggleLLMCacheAsync( + request.Enabled, + userName, + request.Reason, + cancellationToken); + LogAdminAudit("Toggled", "LLMCache", detail: $"Enabled: {request.Enabled}, Reason: {LoggingSanitizer.S(request.Reason)}"); + return result; + }, + Ok, + "ToggleLLMCache"); } } diff --git a/Services/ConduitLLM.Admin/Controllers/FunctionConfigurationsController.cs b/Services/ConduitLLM.Admin/Controllers/FunctionConfigurationsController.cs index 7ed0a7831..18ef46266 100644 --- a/Services/ConduitLLM.Admin/Controllers/FunctionConfigurationsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/FunctionConfigurationsController.cs @@ -1,4 +1,5 @@ using ConduitLLM.Core.Extensions; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Core.Events; using ConduitLLM.Functions.Interfaces; @@ -14,11 +15,9 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class FunctionConfigurationsController : ControllerBase +public class FunctionConfigurationsController : AdminControllerBase { private readonly IFunctionConfigurationRepository _configurationRepository; - private readonly IPublishEndpoint? _publishEndpoint; - private readonly ILogger _logger; /// /// Initializes a new instance of the FunctionConfigurationsController. @@ -27,10 +26,9 @@ public FunctionConfigurationsController( IFunctionConfigurationRepository configurationRepository, IPublishEndpoint? publishEndpoint, ILogger logger) + : base(publishEndpoint, logger) { _configurationRepository = configurationRepository ?? throw new ArgumentNullException(nameof(configurationRepository)); - _publishEndpoint = publishEndpoint; // Nullable for in-memory mode - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -40,18 +38,12 @@ public FunctionConfigurationsController( [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllConfigurations() + public Task GetAllConfigurations() { - try - { - var configurations = await _configurationRepository.GetAllAsync(); - return Ok(configurations); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all function configurations"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _configurationRepository.GetAllUnboundedAsync(), + Ok, + "GetAllConfigurations"); } /// @@ -63,24 +55,14 @@ public async Task GetAllConfigurations() [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetConfigurationById(int id) + public Task GetConfigurationById(int id) { - try - { - var configuration = await _configurationRepository.GetByIdAsync(id); - - if (configuration == null) - { - return NotFound(new ErrorResponseDto("Function configuration not found")); - } - - return Ok(configuration); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function configuration with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _configurationRepository.GetByIdAsync(id), + Ok, + "FunctionConfiguration", + id, + "GetConfigurationById"); } /// @@ -91,23 +73,18 @@ public async Task GetConfigurationById(int id) [HttpGet("provider/{providerType}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetConfigurationsByProvider(string providerType) + public Task GetConfigurationsByProvider(string providerType) { - try - { - if (!Enum.TryParse(providerType, true, out var providerEnum)) - { - return BadRequest(new ErrorResponseDto($"Invalid provider type: {providerType}")); - } - - var configurations = await _configurationRepository.GetByProviderTypeAsync(providerEnum); - return Ok(configurations); - } - catch (Exception ex) + if (!Enum.TryParse(providerType, true, out var providerEnum)) { - _logger.LogError(ex, "Error getting function configurations for provider {ProviderType}", providerType); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(BadRequest(new ErrorResponseDto($"Invalid provider type: {providerType}"))); } + + return ExecuteAsync( + () => _configurationRepository.GetByProviderTypeAsync(providerEnum), + Ok, + "GetConfigurationsByProvider", + new { ProviderType = providerType }); } /// @@ -118,23 +95,18 @@ public async Task GetConfigurationsByProvider(string providerType [HttpGet("purpose/{purpose}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetConfigurationsByPurpose(string purpose) + public Task GetConfigurationsByPurpose(string purpose) { - try + if (!Enum.TryParse(purpose, true, out var purposeEnum)) { - if (!Enum.TryParse(purpose, true, out var purposeEnum)) - { - return BadRequest(new ErrorResponseDto($"Invalid purpose: {purpose}")); - } - - var configurations = await _configurationRepository.GetByPurposeAsync(purposeEnum); - return Ok(configurations); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function configurations for purpose {Purpose}", purpose); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(BadRequest(new ErrorResponseDto($"Invalid purpose: {purpose}"))); } + + return ExecuteAsync( + () => _configurationRepository.GetByPurposeAsync(purposeEnum), + Ok, + "GetConfigurationsByPurpose", + new { Purpose = purpose }); } /// @@ -146,27 +118,28 @@ public async Task GetConfigurationsByPurpose(string purpose) [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateConfiguration( + public Task CreateConfiguration( [FromBody] ConduitLLM.Functions.Entities.FunctionConfiguration configuration) { - try + if (configuration == null) { - if (configuration == null) - { - return BadRequest(new ErrorResponseDto("Function configuration data is required")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("Function configuration data is required"))); + } - int id = await _configurationRepository.CreateAsync(configuration); + return ExecuteAsync( + async () => + { + int id = await _configurationRepository.CreateAsync(configuration); - // Fetch the created entity to return - var created = await _configurationRepository.GetByIdAsync(id); + // Fetch the created entity to return + var created = await _configurationRepository.GetByIdAsync(id); - // Publish FunctionConfigurationChanged event for cache invalidation - if (_publishEndpoint != null && created != null) - { - try + // Audit log and publish event for cache invalidation + if (created != null) { - await _publishEndpoint.Publish(new FunctionConfigurationChanged + LogAdminAudit("Created", "FunctionConfiguration", created.Id, $"Name: {LoggingSanitizer.S(created.ConfigurationName)}"); + AdminOperationsMetricsService.RecordConfigurationChange("functionconfiguration", "create"); + PublishEventFireAndForget(new FunctionConfigurationChanged { FunctionConfigurationId = created.Id, ConfigurationName = created.ConfigurationName, @@ -177,31 +150,17 @@ await _publishEndpoint.Publish(new FunctionConfigurationChanged IsEnabledChanged = false, CacheTtlChanged = false, CorrelationId = Guid.NewGuid().ToString() - }); - - _logger.LogInformation( - "Published FunctionConfigurationChanged event for created configuration '{ConfigName}' (ID: {ConfigId})", - created.ConfigurationName, created.Id); - } - catch (Exception publishEx) - { - // Log but don't fail the request if event publishing fails - _logger.LogError(publishEx, - "Failed to publish FunctionConfigurationChanged event for created configuration '{ConfigName}' (ID: {ConfigId})", - created.ConfigurationName, created.Id); + }, "create function configuration", + new { ConfigName = created.ConfigurationName, ConfigId = created.Id }); } - } - return CreatedAtAction( + return (id, created); + }, + result => CreatedAtAction( nameof(GetConfigurationById), - new { id }, - created); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating function configuration"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + new { id = result.id }, + result.created), + "CreateConfiguration"); } /// @@ -215,60 +174,57 @@ await _publishEndpoint.Publish(new FunctionConfigurationChanged [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateConfiguration( + public Task UpdateConfiguration( int id, [FromBody] ConduitLLM.Functions.Entities.FunctionConfiguration configuration) { - try + if (configuration == null) { - if (configuration == null) - { - return BadRequest(new ErrorResponseDto("Function configuration data is required")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("Function configuration data is required"))); + } - if (id != configuration.Id) - { - return BadRequest(new ErrorResponseDto("ID mismatch")); - } + if (id != configuration.Id) + { + return Task.FromResult(BadRequest(new ErrorResponseDto("ID mismatch"))); + } - // Get existing configuration to detect changes - var existing = await _configurationRepository.GetByIdAsync(id); - if (existing == null) + return ExecuteWithNotFoundAsync( + () => _configurationRepository.GetByIdAsync(id), + async existing => { - return NotFound(new ErrorResponseDto("Function configuration not found")); - } - - // Detect changes for event publishing - bool isEnabledChanged = existing.IsEnabled != configuration.IsEnabled; - bool cacheTtlChanged = existing.CacheTtlMinutes != configuration.CacheTtlMinutes; - var changedProperties = new List(); - if (existing.ConfigurationName != configuration.ConfigurationName) changedProperties.Add("ConfigurationName"); - if (existing.ProviderType != configuration.ProviderType) changedProperties.Add("ProviderType"); - if (existing.Purpose != configuration.Purpose) changedProperties.Add("Purpose"); - if (existing.IsEnabled != configuration.IsEnabled) changedProperties.Add("IsEnabled"); - if (existing.BaseUrl != configuration.BaseUrl) changedProperties.Add("BaseUrl"); - if (existing.TimeoutSeconds != configuration.TimeoutSeconds) changedProperties.Add("TimeoutSeconds"); - if (existing.CacheTtlMinutes != configuration.CacheTtlMinutes) changedProperties.Add("CacheTtlMinutes"); - if (existing.ProviderSettings != configuration.ProviderSettings) changedProperties.Add("ProviderSettings"); - if (existing.ParameterSchema != configuration.ParameterSchema) changedProperties.Add("ParameterSchema"); - if (existing.Description != configuration.Description) changedProperties.Add("Description"); - - await _configurationRepository.UpdateAsync(configuration); - - // Fetch the updated entity to return - var updated = await _configurationRepository.GetByIdAsync(id); + // Detect changes for event publishing + bool isEnabledChanged = existing.IsEnabled != configuration.IsEnabled; + bool cacheTtlChanged = existing.CacheTtlMinutes != configuration.CacheTtlMinutes; + var changedProperties = new List(); + if (existing.ConfigurationName != configuration.ConfigurationName) changedProperties.Add("ConfigurationName"); + if (existing.ProviderType != configuration.ProviderType) changedProperties.Add("ProviderType"); + if (existing.Purpose != configuration.Purpose) changedProperties.Add("Purpose"); + if (existing.IsEnabled != configuration.IsEnabled) changedProperties.Add("IsEnabled"); + if (existing.BaseUrl != configuration.BaseUrl) changedProperties.Add("BaseUrl"); + if (existing.TimeoutSeconds != configuration.TimeoutSeconds) changedProperties.Add("TimeoutSeconds"); + if (existing.CacheTtlMinutes != configuration.CacheTtlMinutes) changedProperties.Add("CacheTtlMinutes"); + if (existing.ProviderSettings != configuration.ProviderSettings) changedProperties.Add("ProviderSettings"); + if (existing.ParameterSchema != configuration.ParameterSchema) changedProperties.Add("ParameterSchema"); + if (existing.Description != configuration.Description) changedProperties.Add("Description"); + + await _configurationRepository.UpdateAsync(configuration); + + // Fetch the updated entity to return + var updated = await _configurationRepository.GetByIdAsync(id); + + if (updated == null) + { + return NotFound(new ErrorResponseDto("Function configuration not found after update")); + } - if (updated == null) - { - return NotFound(new ErrorResponseDto("Function configuration not found")); - } + LogAdminAudit("Updated", "FunctionConfiguration", id, + changedProperties.Count > 0 ? $"Changed: {string.Join(", ", changedProperties)}" : null); + AdminOperationsMetricsService.RecordConfigurationChange("functionconfiguration", "update"); - // Publish FunctionConfigurationChanged event for cache invalidation - if (_publishEndpoint != null && changedProperties.Count > 0) - { - try + // Publish FunctionConfigurationChanged event for cache invalidation + if (changedProperties.Count > 0) { - await _publishEndpoint.Publish(new FunctionConfigurationChanged + PublishEventFireAndForget(new FunctionConfigurationChanged { FunctionConfigurationId = updated.Id, ConfigurationName = updated.ConfigurationName, @@ -279,28 +235,15 @@ await _publishEndpoint.Publish(new FunctionConfigurationChanged IsEnabledChanged = isEnabledChanged, CacheTtlChanged = cacheTtlChanged, CorrelationId = Guid.NewGuid().ToString() - }); - - _logger.LogInformation( - "Published FunctionConfigurationChanged event for updated configuration '{ConfigName}' (ID: {ConfigId}, Changed: {ChangedProps})", - updated.ConfigurationName, updated.Id, string.Join(", ", changedProperties)); + }, "update function configuration", + new { ConfigName = updated.ConfigurationName, ConfigId = updated.Id, ChangedProps = string.Join(", ", changedProperties) }); } - catch (Exception publishEx) - { - // Log but don't fail the request if event publishing fails - _logger.LogError(publishEx, - "Failed to publish FunctionConfigurationChanged event for updated configuration '{ConfigName}' (ID: {ConfigId})", - updated.ConfigurationName, updated.Id); - } - } - return Ok(updated); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating function configuration with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return Ok(updated); + }, + "FunctionConfiguration", + id, + "UpdateConfiguration"); } /// @@ -312,56 +255,35 @@ await _publishEndpoint.Publish(new FunctionConfigurationChanged [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteConfiguration(int id) + public Task DeleteConfiguration(int id) { - try - { - // Get the configuration before deleting for event publishing - var toDelete = await _configurationRepository.GetByIdAsync(id); - if (toDelete == null) - { - return NotFound(new ErrorResponseDto("Function configuration not found")); - } - - await _configurationRepository.DeleteAsync(id); - - // Publish FunctionConfigurationChanged event for cache invalidation - if (_publishEndpoint != null) + return ExecuteWithNotFoundAsync( + () => _configurationRepository.GetByIdAsync(id), + async toDelete => { - try - { - await _publishEndpoint.Publish(new FunctionConfigurationChanged - { - FunctionConfigurationId = toDelete.Id, - ConfigurationName = toDelete.ConfigurationName, - ProviderType = toDelete.ProviderType.ToString(), - Purpose = toDelete.Purpose.ToString(), - ChangeType = "Deleted", - ChangedProperties = new[] { "Deleted" }, - IsEnabledChanged = false, - CacheTtlChanged = false, - CorrelationId = Guid.NewGuid().ToString() - }); + await _configurationRepository.DeleteAsync(id); + LogAdminAudit("Deleted", "FunctionConfiguration", id, $"Name: {LoggingSanitizer.S(toDelete.ConfigurationName)}"); + AdminOperationsMetricsService.RecordConfigurationChange("functionconfiguration", "delete"); - _logger.LogInformation( - "Published FunctionConfigurationChanged event for deleted configuration '{ConfigName}' (ID: {ConfigId})", - toDelete.ConfigurationName, toDelete.Id); - } - catch (Exception publishEx) + // Publish FunctionConfigurationChanged event for cache invalidation + PublishEventFireAndForget(new FunctionConfigurationChanged { - // Log but don't fail the request if event publishing fails - _logger.LogError(publishEx, - "Failed to publish FunctionConfigurationChanged event for deleted configuration '{ConfigName}' (ID: {ConfigId})", - toDelete.ConfigurationName, toDelete.Id); - } - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting function configuration with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + FunctionConfigurationId = toDelete.Id, + ConfigurationName = toDelete.ConfigurationName, + ProviderType = toDelete.ProviderType.ToString(), + Purpose = toDelete.Purpose.ToString(), + ChangeType = "Deleted", + ChangedProperties = new[] { "Deleted" }, + IsEnabledChanged = false, + CacheTtlChanged = false, + CorrelationId = Guid.NewGuid().ToString() + }, "delete function configuration", + new { ConfigName = toDelete.ConfigurationName, ConfigId = toDelete.Id }); + + return NoContent(); + }, + "FunctionConfiguration", + id, + "DeleteConfiguration"); } } diff --git a/Services/ConduitLLM.Admin/Controllers/FunctionCostsController.cs b/Services/ConduitLLM.Admin/Controllers/FunctionCostsController.cs index 59b3764d5..627876cc1 100644 --- a/Services/ConduitLLM.Admin/Controllers/FunctionCostsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/FunctionCostsController.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Admin.Extensions; using ConduitLLM.Core.Extensions; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Functions.DTOs; @@ -14,10 +15,9 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class FunctionCostsController : ControllerBase +public class FunctionCostsController : AdminControllerBase { private readonly IFunctionCostService _functionCostService; - private readonly ILogger _logger; /// /// Initializes a new instance of the FunctionCostsController. @@ -25,9 +25,9 @@ public class FunctionCostsController : ControllerBase public FunctionCostsController( IFunctionCostService functionCostService, ILogger logger) + : base(logger) { _functionCostService = functionCostService ?? throw new ArgumentNullException(nameof(functionCostService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -37,19 +37,16 @@ public FunctionCostsController( [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllFunctionCosts() + public Task GetAllFunctionCosts() { - try - { - var functionCosts = await _functionCostService.ListCostsAsync(); - var dtos = functionCosts.Select(MapToDto).ToList(); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all function costs"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var functionCosts = await _functionCostService.ListCostsAsync(); + return functionCosts.Select(e => e.ToDto()).ToList(); + }, + Ok, + "GetAllFunctionCosts"); } /// @@ -61,25 +58,18 @@ public async Task GetAllFunctionCosts() [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetFunctionCostById(int id) + public Task GetFunctionCostById(int id) { - try - { - var functionCost = await _functionCostService.GetCostByIdAsync(id); - - if (functionCost == null) + return ExecuteWithNotFoundAsync( + async () => { - return NotFound(new ErrorResponseDto("Function cost not found")); - } - - var dto = MapToDto(functionCost); - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function cost with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var functionCost = await _functionCostService.GetCostByIdAsync(id); + return functionCost?.ToDto(); + }, + Ok, + "Function cost", + id, + "GetFunctionCostById"); } /// @@ -91,29 +81,19 @@ public async Task GetFunctionCostById(int id) [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetCostForConfiguration(int functionConfigurationId) + public Task GetCostForConfiguration(int functionConfigurationId) { - try - { - var functionCost = await _functionCostService.GetCostForConfigurationAsync( - functionConfigurationId); - - if (functionCost == null) + return ExecuteWithNotFoundAsync( + async () => { - return NotFound(new ErrorResponseDto( - $"No active cost found for function configuration {functionConfigurationId}")); - } - - var dto = MapToDto(functionCost); - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error getting function cost for configuration {FunctionConfigurationId}", - functionConfigurationId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var functionCost = await _functionCostService.GetCostForConfigurationAsync( + functionConfigurationId); + return functionCost?.ToDto(); + }, + Ok, + "Function cost for configuration", + functionConfigurationId, + "GetCostForConfiguration"); } /// @@ -125,33 +105,32 @@ public async Task GetCostForConfiguration(int functionConfigurati [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateFunctionCost( + public Task CreateFunctionCost( [FromBody] CreateFunctionCostDto createDto) { - try + if (createDto == null) { - if (createDto == null) - { - return BadRequest(new ErrorResponseDto("Function cost data is required")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("Function cost data is required"))); + } - var entity = MapToEntity(createDto); - int id = await _functionCostService.CreateCostAsync(entity); + return ExecuteAsync( + async () => + { + var entity = MapToEntity(createDto); + int id = await _functionCostService.CreateCostAsync(entity); - // Fetch the created entity to return as DTO - var created = await _functionCostService.GetCostByIdAsync(id); - var dto = created != null ? MapToDto(created) : null; + // Fetch the created entity to return as DTO + var created = await _functionCostService.GetCostByIdAsync(id); + var dto = created?.ToDto(); - return CreatedAtAction( + LogAdminAudit("Created", "FunctionCost", id, $"CostName: {LoggingSanitizer.S(createDto.CostName)}"); + return (id, dto); + }, + result => CreatedAtAction( nameof(GetFunctionCostById), - new { id }, - dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating function cost"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + new { id = result.id }, + result.dto), + "CreateFunctionCost"); } /// @@ -165,44 +144,42 @@ public async Task CreateFunctionCost( [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateFunctionCost( + public Task UpdateFunctionCost( int id, [FromBody] UpdateFunctionCostDto updateDto) { - try + if (updateDto == null) { - if (updateDto == null) - { - return BadRequest(new ErrorResponseDto("Function cost data is required")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("Function cost data is required"))); + } - if (id != updateDto.Id) - { - return BadRequest(new ErrorResponseDto("ID mismatch")); - } + if (id != updateDto.Id) + { + return Task.FromResult(BadRequest(new ErrorResponseDto("ID mismatch"))); + } - // Get existing entity to preserve fields not in update DTO - var existing = await _functionCostService.GetCostByIdAsync(id); - if (existing == null) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Function cost not found")); - } + // Get existing entity to preserve fields not in update DTO + var existing = await _functionCostService.GetCostByIdAsync(id); + if (existing == null) + { + throw new KeyNotFoundException(); + } - // Map update DTO to entity, preserving ProviderType from existing - var entity = MapToEntity(updateDto, existing); - await _functionCostService.UpdateCostAsync(entity); + // Map update DTO to entity, preserving ProviderType from existing + var entity = MapToEntity(updateDto, existing); + await _functionCostService.UpdateCostAsync(entity); - // Fetch the updated entity to return - var updated = await _functionCostService.GetCostByIdAsync(id); - var dto = updated != null ? MapToDto(updated) : null; - - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating function cost with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + // Fetch the updated entity to return + var updated = await _functionCostService.GetCostByIdAsync(id); + LogAdminAudit("Updated", "FunctionCost", id, $"CostName: {LoggingSanitizer.S(updateDto.CostName)}"); + return updated?.ToDto(); + }, + dto => Ok(dto), + "UpdateFunctionCost", + new { Id = id }); } /// @@ -214,19 +191,18 @@ public async Task UpdateFunctionCost( [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteFunctionCost(int id) + public Task DeleteFunctionCost(int id) { - try - { - await _functionCostService.DeleteCostAsync(id); - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting function cost with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var existing = await _functionCostService.GetCostByIdAsync(id); + await _functionCostService.DeleteCostAsync(id); + LogAdminAudit("Deleted", "FunctionCost", id, existing != null ? $"CostName: {LoggingSanitizer.S(existing.CostName)}" : null); + }, + NoContent(), + "DeleteFunctionCost", + new { Id = id }); } /// @@ -236,49 +212,21 @@ public async Task DeleteFunctionCost(int id) [HttpPost("cache/clear")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ClearCache() + public Task ClearCache() { - try - { - await _functionCostService.ClearCacheAsync(); - - return Ok(new { message = "Function cost cache cleared successfully" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error clearing function cost cache"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + await _functionCostService.ClearCacheAsync(); + LogAdminAudit("Cleared", "FunctionCostCache"); + return new { message = "Function cost cache cleared successfully" }; + }, + Ok, + "ClearCache"); } // Mapping methods - private static FunctionCostDto MapToDto(FunctionCost entity) - { - return new FunctionCostDto - { - Id = entity.Id, - CostName = entity.CostName, - ProviderType = entity.ProviderType, - Purpose = entity.Purpose, - Description = entity.Description, - BaseCost = entity.BaseCost, - PricingModel = entity.PricingModel, - CostPerExecution = entity.CostPerExecution, - CostPerResult = entity.CostPerResult, - CostPerToken = entity.CostPerToken, - CostPerMinute = entity.CostPerMinute, - TieredPricing = entity.TieredPricing, - PricingConfiguration = entity.PricingConfiguration, - IsActive = entity.IsActive, - EffectiveDate = entity.EffectiveDate, - ExpiryDate = entity.ExpiryDate, - Priority = entity.Priority, - CreatedAt = entity.CreatedAt, - UpdatedAt = entity.UpdatedAt - }; - } - private static FunctionCost MapToEntity(CreateFunctionCostDto dto) { return new FunctionCost diff --git a/Services/ConduitLLM.Admin/Controllers/FunctionCredentialsController.cs b/Services/ConduitLLM.Admin/Controllers/FunctionCredentialsController.cs index 5aab488fa..0cb0cc6ab 100644 --- a/Services/ConduitLLM.Admin/Controllers/FunctionCredentialsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/FunctionCredentialsController.cs @@ -12,12 +12,11 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class FunctionCredentialsController : ControllerBase +public class FunctionCredentialsController : AdminControllerBase { private readonly IFunctionCredentialRepository _credentialRepository; private readonly IFunctionConfigurationRepository _configurationRepository; private readonly IFunctionClientFactory _clientFactory; - private readonly ILogger _logger; /// /// Initializes a new instance of the FunctionCredentialsController. @@ -27,11 +26,11 @@ public FunctionCredentialsController( IFunctionConfigurationRepository configurationRepository, IFunctionClientFactory clientFactory, ILogger logger) + : base(logger) { _credentialRepository = credentialRepository ?? throw new ArgumentNullException(nameof(credentialRepository)); _configurationRepository = configurationRepository ?? throw new ArgumentNullException(nameof(configurationRepository)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -41,18 +40,12 @@ public FunctionCredentialsController( [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllCredentials() + public Task GetAllCredentials() { - try - { - var credentials = await _credentialRepository.GetAllAsync(); - return Ok(credentials); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all function credentials"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _credentialRepository.GetAllUnboundedAsync(), + Ok, + "GetAllCredentials"); } /// @@ -64,30 +57,25 @@ public async Task GetAllCredentials() [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetCredentialsByConfiguration(int functionConfigurationId) + public Task GetCredentialsByConfiguration(int functionConfigurationId) { - try - { - // Get the configuration to determine its provider type - var configuration = await _configurationRepository.GetByIdAsync(functionConfigurationId); - if (configuration == null) + return ExecuteAsync( + async () => { - return NotFound($"Function configuration {functionConfigurationId} not found"); - } - - // Get credentials for this provider type - var credentials = await _credentialRepository.GetByProviderTypeAsync( - configuration.ProviderType); + // Get the configuration to determine its provider type + var configuration = await _configurationRepository.GetByIdAsync(functionConfigurationId); + if (configuration == null) + { + throw new KeyNotFoundException(); + } - return Ok(credentials); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error getting credentials for function configuration {FunctionConfigurationId}", - functionConfigurationId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + // Get credentials for this provider type + return await _credentialRepository.GetByProviderTypeAsync( + configuration.ProviderType); + }, + Ok, + "GetCredentialsByConfiguration", + new { FunctionConfigurationId = functionConfigurationId }); } /// @@ -99,24 +87,14 @@ public async Task GetCredentialsByConfiguration(int functionConfi [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetCredentialById(int id) + public Task GetCredentialById(int id) { - try - { - var credential = await _credentialRepository.GetByIdAsync(id); - - if (credential == null) - { - return NotFound(new ErrorResponseDto("Function credential not found")); - } - - return Ok(credential); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function credential with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _credentialRepository.GetByIdAsync(id), + Ok, + "Function credential", + id, + "GetCredentialById"); } /// @@ -128,31 +106,33 @@ public async Task GetCredentialById(int id) [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateCredential( + public Task CreateCredential( [FromBody] ConduitLLM.Functions.Entities.FunctionCredential credential) { - try + if (credential == null) { - if (credential == null) - { - return BadRequest(new ErrorResponseDto("Function credential data is required")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("Function credential data is required"))); + } - int id = await _credentialRepository.CreateAsync(credential); + return ExecuteAsync( + async () => + { + int id = await _credentialRepository.CreateAsync(credential); - // Fetch the created entity to return - var created = await _credentialRepository.GetByIdAsync(id); + // Fetch the created entity to return + var created = await _credentialRepository.GetByIdAsync(id); - return CreatedAtAction( - nameof(GetCredentialById), - new { id }, - created); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating function credential"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return (id, created); + }, + result => + { + LogAdminAudit("Created", "FunctionCredential", result.id, $"ProviderType: {credential.ProviderType}"); + return CreatedAtAction( + nameof(GetCredentialById), + new { id = result.id }, + result.created); + }, + "CreateCredential"); } /// @@ -166,39 +146,42 @@ public async Task CreateCredential( [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateCredential( + public Task UpdateCredential( int id, [FromBody] ConduitLLM.Functions.Entities.FunctionCredential credential) { - try + if (credential == null) { - if (credential == null) - { - return BadRequest(new ErrorResponseDto("Function credential data is required")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("Function credential data is required"))); + } + + if (id != credential.Id) + { + return Task.FromResult(BadRequest(new ErrorResponseDto("ID mismatch"))); + } - if (id != credential.Id) + return ExecuteAsync( + async () => { - return BadRequest(new ErrorResponseDto("ID mismatch")); - } + await _credentialRepository.UpdateAsync(credential); - await _credentialRepository.UpdateAsync(credential); + // Fetch the updated entity to return + var updated = await _credentialRepository.GetByIdAsync(id); - // Fetch the updated entity to return - var updated = await _credentialRepository.GetByIdAsync(id); + if (updated == null) + { + throw new KeyNotFoundException(); + } - if (updated == null) + return updated; + }, + result => { - return NotFound(new ErrorResponseDto("Function credential not found")); - } - - return Ok(updated); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating function credential with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + LogAdminAudit("Updated", "FunctionCredential", id, $"ProviderType: {credential.ProviderType}"); + return Ok(result); + }, + "UpdateCredential", + new { Id = id }); } /// @@ -210,19 +193,18 @@ public async Task UpdateCredential( [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteCredential(int id) + public Task DeleteCredential(int id) { - try - { - await _credentialRepository.DeleteAsync(id); - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting function credential with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var credential = await _credentialRepository.GetByIdAsync(id); + await _credentialRepository.DeleteAsync(id); + LogAdminAudit("Deleted", "FunctionCredential", id, credential != null ? $"ProviderType: {credential.ProviderType}" : null); + }, + NoContent(), + "DeleteCredential", + new { Id = id }); } /// @@ -234,51 +216,49 @@ public async Task DeleteCredential(int id) [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task TestCredential([FromBody] TestCredentialRequest testRequest) + public Task TestCredential([FromBody] TestCredentialRequest testRequest) { - try + if (testRequest == null) { - if (testRequest == null) - { - return BadRequest(new ErrorResponseDto("Test request data is required")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("Test request data is required"))); + } - // Get the credential - var credential = await _credentialRepository.GetByIdAsync(testRequest.CredentialId); - if (credential == null) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Function credential not found")); - } + // Get the credential + var credential = await _credentialRepository.GetByIdAsync(testRequest.CredentialId); + if (credential == null) + { + throw new KeyNotFoundException(); + } - // Get any configuration that uses this provider type (for client factory) - var configurations = await _configurationRepository.GetByProviderTypeAsync(credential.ProviderType); - var configuration = configurations.FirstOrDefault(); - if (configuration == null) - { - return NotFound(new ErrorResponseDto($"No function configuration found for provider type {credential.ProviderType}")); - } + // Get any configuration that uses this provider type (for client factory) + var configurations = await _configurationRepository.GetByProviderTypeAsync(credential.ProviderType); + var configuration = configurations.FirstOrDefault(); + if (configuration == null) + { + throw new KeyNotFoundException(); + } - // Create client and test authentication - var client = _clientFactory.GetClient( - credential.ProviderType, - configuration.Id); + // Create client and test authentication + var client = await _clientFactory.GetClientAsync( + credential.ProviderType, + configuration.Id); - var authResult = await client.VerifyAuthenticationAsync( - testRequest.ApiKeyOverride ?? credential.ApiKey); + var authResult = await client.VerifyAuthenticationAsync( + testRequest.ApiKeyOverride ?? credential.ApiKey); - return Ok(new - { - success = authResult.IsSuccess, - message = authResult.Message, - details = authResult.Details, - durationMs = authResult.ResponseTimeMs - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error testing function credential"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return new + { + success = authResult.IsSuccess, + message = authResult.Message, + details = authResult.Details, + durationMs = authResult.ResponseTimeMs + }; + }, + Ok, + "TestCredential"); } /// diff --git a/Services/ConduitLLM.Admin/Controllers/FunctionExecutionsController.cs b/Services/ConduitLLM.Admin/Controllers/FunctionExecutionsController.cs index 445ab38d3..abfcf9cad 100644 --- a/Services/ConduitLLM.Admin/Controllers/FunctionExecutionsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/FunctionExecutionsController.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Admin.Extensions; using ConduitLLM.Core.Extensions; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Functions.DTOs; @@ -15,10 +16,9 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class FunctionExecutionsController : ControllerBase +public class FunctionExecutionsController : AdminControllerBase { private readonly IFunctionExecutionRepository _executionRepository; - private readonly ILogger _logger; /// /// Initializes a new instance of the FunctionExecutionsController. @@ -26,9 +26,9 @@ public class FunctionExecutionsController : ControllerBase public FunctionExecutionsController( IFunctionExecutionRepository executionRepository, ILogger logger) + : base(logger) { _executionRepository = executionRepository ?? throw new ArgumentNullException(nameof(executionRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -40,25 +40,18 @@ public FunctionExecutionsController( [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetExecutionById(Guid id) + public Task GetExecutionById(Guid id) { - try - { - var execution = await _executionRepository.GetByIdAsync(id); - - if (execution == null) + return ExecuteWithNotFoundAsync( + async () => { - return NotFound(new ErrorResponseDto("Function execution not found")); - } - - var dto = MapToDto(execution); - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function execution with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var execution = await _executionRepository.GetByIdAsync(id); + return execution?.ToDto(); + }, + Ok, + "Function execution", + id, + "GetExecutionById"); } /// @@ -69,21 +62,17 @@ public async Task GetExecutionById(Guid id) [HttpGet("virtualkey/{virtualKeyId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetExecutionsByVirtualKey(int virtualKeyId) + public Task GetExecutionsByVirtualKey(int virtualKeyId) { - try - { - var executions = await _executionRepository.GetByVirtualKeyIdAsync(virtualKeyId); - var dtos = executions.Select(MapToDto).ToList(); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error getting function executions for virtual key {VirtualKeyId}", - virtualKeyId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var executions = await _executionRepository.GetByVirtualKeyIdAsync(virtualKeyId); + return executions.Select(e => e.ToDto()).ToList(); + }, + Ok, + "GetExecutionsByVirtualKey", + new { VirtualKeyId = virtualKeyId }); } /// @@ -94,22 +83,18 @@ public async Task GetExecutionsByVirtualKey(int virtualKeyId) [HttpGet("configuration/{functionConfigurationId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetExecutionsByConfiguration(int functionConfigurationId) + public Task GetExecutionsByConfiguration(int functionConfigurationId) { - try - { - var executions = await _executionRepository.GetByFunctionConfigurationIdAsync( - functionConfigurationId); - var dtos = executions.Select(MapToDto).ToList(); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error getting function executions for configuration {FunctionConfigurationId}", - functionConfigurationId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var executions = await _executionRepository.GetByFunctionConfigurationIdAsync( + functionConfigurationId); + return executions.Select(e => e.ToDto()).ToList(); + }, + Ok, + "GetExecutionsByConfiguration", + new { FunctionConfigurationId = functionConfigurationId }); } /// @@ -121,24 +106,22 @@ public async Task GetExecutionsByConfiguration(int functionConfig [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetExecutionsByState(string state) + public Task GetExecutionsByState(string state) { - try + if (!Enum.TryParse(state, true, out var stateEnum)) { - if (!Enum.TryParse(state, true, out var stateEnum)) - { - return BadRequest(new ErrorResponseDto($"Invalid execution state: {state}")); - } - - var executions = await _executionRepository.GetByStateAsync(stateEnum); - var dtos = executions.Select(MapToDto).ToList(); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function executions for state {State}", state); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); + return Task.FromResult(BadRequest(new ErrorResponseDto($"Invalid execution state: {state}"))); } + + return ExecuteAsync( + async () => + { + var executions = await _executionRepository.GetByStateAsync(stateEnum); + return executions.Select(e => e.ToDto()).ToList(); + }, + Ok, + "GetExecutionsByState", + new { State = state }); } /// @@ -148,19 +131,16 @@ public async Task GetExecutionsByState(string state) [HttpGet("expired-leases")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetExpiredLeases() + public Task GetExpiredLeases() { - try - { - var executions = await _executionRepository.GetExpiredLeasesAsync(); - var dtos = executions.Select(MapToDto).ToList(); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function executions with expired leases"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var executions = await _executionRepository.GetExpiredLeasesAsync(); + return executions.Select(e => e.ToDto()).ToList(); + }, + Ok, + "GetExpiredLeases"); } /// @@ -170,19 +150,16 @@ public async Task GetExpiredLeases() [HttpGet("ready-for-retry")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetReadyForRetry() + public Task GetReadyForRetry() { - try - { - var executions = await _executionRepository.GetReadyForRetryAsync(); - var dtos = executions.Select(MapToDto).ToList(); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function executions ready for retry"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var executions = await _executionRepository.GetReadyForRetryAsync(); + return executions.Select(e => e.ToDto()).ToList(); + }, + Ok, + "GetReadyForRetry"); } /// @@ -194,64 +171,31 @@ public async Task GetReadyForRetry() [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CleanupOldExecutions([FromQuery] int olderThanDays = 30) + public Task CleanupOldExecutions([FromQuery] int olderThanDays = 30) { - try + if (olderThanDays < 1) { - if (olderThanDays < 1) - { - return BadRequest(new ErrorResponseDto("olderThanDays must be at least 1")); - } - - var olderThan = DateTime.UtcNow.AddDays(-olderThanDays); - var deletedCount = await _executionRepository.DeleteOldExecutionsAsync(olderThan); + return Task.FromResult(BadRequest(new ErrorResponseDto("olderThanDays must be at least 1"))); + } - return Ok(new + return ExecuteAsync( + async () => { - deletedCount, - message = $"Deleted {deletedCount} executions older than {olderThanDays} days" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error cleaning up old function executions"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var olderThan = DateTime.UtcNow.AddDays(-olderThanDays); + var deletedCount = await _executionRepository.DeleteOldExecutionsAsync(olderThan); + + LogAdminAudit("Cleanup", "FunctionExecution", + detail: $"OlderThanDays: {olderThanDays}, DeletedCount: {deletedCount}"); + + return new + { + deletedCount, + message = $"Deleted {deletedCount} executions older than {olderThanDays} days" + }; + }, + Ok, + "CleanupOldExecutions", + new { OlderThanDays = olderThanDays }); } - // Mapping methods - - /// - /// Maps FunctionExecution entity to DTO, converting TimeSpan to milliseconds - /// - private static FunctionExecutionDto MapToDto(FunctionExecution entity) - { - return new FunctionExecutionDto - { - Id = entity.Id, - FunctionConfigurationId = entity.FunctionConfigurationId, - VirtualKeyId = entity.VirtualKeyId, - ExecutionMode = entity.ExecutionMode, - State = entity.State, - RequestedAt = entity.RequestedAt, - StartedAt = entity.StartedAt, - CompletedAt = entity.CompletedAt, - Duration = entity.Duration?.TotalMilliseconds, - RequestJson = entity.RequestJson, - ResponseJson = entity.ResponseJson, - ErrorMessage = entity.ErrorMessage, - EstimatedCost = entity.EstimatedCost, - ActualCost = entity.ActualCost, - CostCalculationDetails = entity.CostCalculationDetails, - RetryCount = entity.RetryCount, - NextRetryAt = entity.NextRetryAt, - LeasedBy = entity.LeasedBy, - LeaseExpiryTime = entity.LeaseExpiryTime, - Version = entity.Version, - WebhookUrl = entity.WebhookUrl, - WebhookDelivered = entity.WebhookDelivered, - ProgressPercentage = entity.ProgressPercentage, - StatusMessage = entity.StatusMessage - }; - } } diff --git a/Services/ConduitLLM.Admin/Controllers/GlobalSettingsController.cs b/Services/ConduitLLM.Admin/Controllers/GlobalSettingsController.cs index bc89729ce..b930b0317 100644 --- a/Services/ConduitLLM.Admin/Controllers/GlobalSettingsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/GlobalSettingsController.cs @@ -1,5 +1,6 @@ using ConduitLLM.Core.Extensions; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Interfaces; @@ -14,11 +15,10 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class GlobalSettingsController : ControllerBase + public class GlobalSettingsController : AdminControllerBase { private readonly IAdminGlobalSettingService _globalSettingService; private readonly IGlobalSettingsCacheService _cacheService; - private readonly ILogger _logger; /// /// Initializes a new instance of the GlobalSettingsController @@ -30,10 +30,10 @@ public GlobalSettingsController( IAdminGlobalSettingService globalSettingService, IGlobalSettingsCacheService cacheService, ILogger logger) + : base(logger) { _globalSettingService = globalSettingService ?? throw new ArgumentNullException(nameof(globalSettingService)); _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -43,18 +43,12 @@ public GlobalSettingsController( [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllSettings() + public Task GetAllSettings() { - try - { - var settings = await _globalSettingService.GetAllSettingsAsync(); - return Ok(settings); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all global settings"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _globalSettingService.GetAllSettingsAsync(), + Ok, + "GetAllSettings"); } /// @@ -66,24 +60,14 @@ public async Task GetAllSettings() [ProducesResponseType(typeof(GlobalSettingDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetSettingById(int id) + public Task GetSettingById(int id) { - try - { - var setting = await _globalSettingService.GetSettingByIdAsync(id); - - if (setting == null) - { - return NotFound(new ErrorResponseDto("Global setting not found")); - } - - return Ok(setting); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting global setting with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _globalSettingService.GetSettingByIdAsync(id), + Ok, + "Global setting", + id, + "GetSettingById"); } /// @@ -95,24 +79,14 @@ public async Task GetSettingById(int id) [ProducesResponseType(typeof(GlobalSettingDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetSettingByKey(string key) + public Task GetSettingByKey(string key) { - try - { - var setting = await _globalSettingService.GetSettingByKeyAsync(key); - - if (setting == null) - { - return NotFound(new ErrorResponseDto("Global setting not found")); - } - - return Ok(setting); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting global setting with key {Key}", LoggingSanitizer.S(key)); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _globalSettingService.GetSettingByKeyAsync(key), + Ok, + "Global setting", + key, + "GetSettingByKey"); } /// @@ -124,28 +98,17 @@ public async Task GetSettingByKey(string key) [ProducesResponseType(typeof(GlobalSettingDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateSetting([FromBody] CreateGlobalSettingDto setting) + public Task CreateSetting([FromBody] CreateGlobalSettingDto setting) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var createdSetting = await _globalSettingService.CreateSettingAsync(setting); - return CreatedAtAction(nameof(GetSettingById), new { id = createdSetting.Id }, createdSetting); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation when creating global setting"); - return BadRequest(ex.Message); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating global setting"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _globalSettingService.CreateSettingAsync(setting), + createdSetting => + { + LogAdminAudit("Created", "GlobalSetting", createdSetting.Id, $"Key: {LoggingSanitizer.S(setting.Key)}"); + AdminOperationsMetricsService.RecordConfigurationChange("globalsetting", "create"); + return CreatedAtAction(nameof(GetSettingById), new { id = createdSetting.Id }, createdSetting); + }, + "CreateSetting"); } /// @@ -159,35 +122,47 @@ public async Task CreateSetting([FromBody] CreateGlobalSettingDto [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateSetting(int id, [FromBody] UpdateGlobalSettingDto setting) + public Task UpdateSetting(int id, [FromBody] UpdateGlobalSettingDto setting) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - // Ensure ID in route matches ID in body if (id != setting.Id) { - return BadRequest("ID in route must match ID in body"); + return Task.FromResult(BadRequest("ID in route must match ID in body")); } - try - { - var success = await _globalSettingService.UpdateSettingAsync(setting); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Global setting not found")); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating global setting with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + // Fetch pre-state for change tracking + var preState = await _globalSettingService.GetSettingByIdAsync(id); + if (preState == null) + throw new KeyNotFoundException(); + + if (!await _globalSettingService.UpdateSettingAsync(setting)) + throw new KeyNotFoundException(); + + // Build change list from pre-state vs request + var changes = new List<(string Property, string? OldValue, string? NewValue)>(); + + if (setting.Value != null && preState.Value != setting.Value) + changes.Add(("Value", preState.Value, setting.Value)); + if (setting.Description != null && preState.Description != setting.Description) + changes.Add(("Description", preState.Description, setting.Description)); + + if (changes.Count > 0) + { + LogAdminAuditWithChanges("GlobalSetting", id, changes, + $"Key: {LoggingSanitizer.S(preState.Key)}"); + } + else + { + LogAdminAudit("Updated", "GlobalSetting", id); + } + AdminOperationsMetricsService.RecordConfigurationChange("globalsetting", "update"); + }, + NoContent(), + "UpdateSetting", + new { Id = id }); } /// @@ -199,29 +174,19 @@ public async Task UpdateSetting(int id, [FromBody] UpdateGlobalSe [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateSettingByKey([FromBody] UpdateGlobalSettingByKeyDto setting) + public Task UpdateSettingByKey([FromBody] UpdateGlobalSettingByKeyDto setting) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var success = await _globalSettingService.UpdateSettingByKeyAsync(setting); - - if (!success) + return ExecuteAsync( + async () => { - return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update or create global setting"); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating global setting with key {Key}", LoggingSanitizer.S(setting.Key)); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!await _globalSettingService.UpdateSettingByKeyAsync(setting)) + throw new InvalidOperationException("Failed to update or create global setting"); + LogAdminAudit("Updated", "GlobalSetting", detail: $"Key: {LoggingSanitizer.S(setting.Key)}"); + AdminOperationsMetricsService.RecordConfigurationChange("globalsetting", "update"); + }, + NoContent(), + "UpdateSettingByKey", + new { Key = setting.Key }); } /// @@ -233,24 +198,19 @@ public async Task UpdateSettingByKey([FromBody] UpdateGlobalSetti [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteSetting(int id) + public Task DeleteSetting(int id) { - try - { - var success = await _globalSettingService.DeleteSettingAsync(id); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Global setting not found")); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting global setting with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!await _globalSettingService.DeleteSettingAsync(id)) + throw new KeyNotFoundException(); + LogAdminAudit("Deleted", "GlobalSetting", id); + AdminOperationsMetricsService.RecordConfigurationChange("globalsetting", "delete"); + }, + NoContent(), + "DeleteSetting", + new { Id = id }); } /// @@ -262,24 +222,19 @@ public async Task DeleteSetting(int id) [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteSettingByKey(string key) + public Task DeleteSettingByKey(string key) { - try - { - var success = await _globalSettingService.DeleteSettingByKeyAsync(key); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Global setting not found")); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting global setting with key {Key}", LoggingSanitizer.S(key)); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!await _globalSettingService.DeleteSettingByKeyAsync(key)) + throw new KeyNotFoundException(); + LogAdminAudit("Deleted", "GlobalSetting", detail: $"Key: {LoggingSanitizer.S(key)}"); + AdminOperationsMetricsService.RecordConfigurationChange("globalsetting", "delete"); + }, + NoContent(), + "DeleteSettingByKey", + new { Key = key }); } /// @@ -289,30 +244,25 @@ public async Task DeleteSettingByKey(string key) [HttpGet("cache/stats")] [ProducesResponseType(typeof(GlobalSettingCacheStatsDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetCacheStats() + public Task GetCacheStats() { - try - { - var stats = await _cacheService.GetCacheStatsAsync(); - - var dto = new GlobalSettingCacheStatsDto + return ExecuteAsync( + async () => { - CacheSize = (int)stats["CacheSize"], - CacheHits = (long)stats["CacheHits"], - CacheMisses = (long)stats["CacheMisses"], - Invalidations = (long)stats["Invalidations"], - HitRate = (double)stats["HitRate"], - LastLoadTime = (DateTime)stats["LastLoadTime"], - CachedKeys = (List)stats["CachedKeys"] - }; - - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting cache statistics"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var stats = await _cacheService.GetCacheStatsAsync(); + return new GlobalSettingCacheStatsDto + { + CacheSize = (int)stats["CacheSize"], + CacheHits = (long)stats["CacheHits"], + CacheMisses = (long)stats["CacheMisses"], + Invalidations = (long)stats["Invalidations"], + HitRate = (double)stats["HitRate"], + LastLoadTime = (DateTime)stats["LastLoadTime"], + CachedKeys = (List)stats["CachedKeys"] + }; + }, + Ok, + "GetCacheStats"); } /// @@ -322,20 +272,16 @@ public async Task GetCacheStats() [HttpPost("cache/reload")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ReloadCache() + public Task ReloadCache() { - try - { - _logger.LogInformation("Manual cache reload requested"); - await _cacheService.ReloadAllSettingsAsync(); - _logger.LogInformation("Cache reload completed successfully"); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error reloading cache"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + await _cacheService.ReloadAllSettingsAsync(); + LogAdminAudit("Reloaded", "GlobalSettingsCache"); + }, + NoContent(), + "ReloadCache"); } /// @@ -346,20 +292,17 @@ public async Task ReloadCache() [HttpPost("cache/invalidate/{key}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task InvalidateCacheSetting(string key) + public Task InvalidateCacheSetting(string key) { - try - { - _logger.LogInformation("Manual cache invalidation requested for key {Key}", LoggingSanitizer.S(key)); - await _cacheService.InvalidateSettingAsync(key); - _logger.LogInformation("Cache invalidation completed for key {Key}", LoggingSanitizer.S(key)); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error invalidating cached setting with key {Key}", LoggingSanitizer.S(key)); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + await _cacheService.InvalidateSettingAsync(key); + LogAdminAudit("Invalidated", "GlobalSettingsCache", detail: $"Key: {LoggingSanitizer.S(key)}"); + }, + NoContent(), + "InvalidateCacheSetting", + new { Key = key }); } } } diff --git a/Services/ConduitLLM.Admin/Controllers/HealthMonitoringController.cs b/Services/ConduitLLM.Admin/Controllers/HealthMonitoringController.cs index c0c9ebbfd..c40dc2489 100644 --- a/Services/ConduitLLM.Admin/Controllers/HealthMonitoringController.cs +++ b/Services/ConduitLLM.Admin/Controllers/HealthMonitoringController.cs @@ -1,5 +1,6 @@ using System.Diagnostics; +using ConduitLLM.Admin.Interfaces; using ConduitLLM.Configuration; using Microsoft.AspNetCore.Mvc; @@ -13,11 +14,11 @@ namespace ConduitLLM.Admin.Controllers /// [ApiController] [Route("api/health")] - public class HealthMonitoringController : ControllerBase + public class HealthMonitoringController : AdminControllerBase { private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; private readonly IMemoryCache _cache; + private readonly IAdminSystemInfoService _systemInfoService; /// /// Initializes a new instance of the class. @@ -25,14 +26,17 @@ public class HealthMonitoringController : ControllerBase /// Database context factory. /// Logger instance. /// Memory cache. + /// System info service for database metrics. public HealthMonitoringController( IDbContextFactory dbContextFactory, ILogger logger, - IMemoryCache cache) + IMemoryCache cache, + IAdminSystemInfoService systemInfoService) + : base(logger) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _systemInfoService = systemInfoService ?? throw new ArgumentNullException(nameof(systemInfoService)); } /// @@ -41,91 +45,89 @@ public HealthMonitoringController( /// Cancellation token. /// Service health information. [HttpGet("services")] - public async Task GetServiceHealth(CancellationToken cancellationToken = default) + public Task GetServiceHealth(CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var services = new List(); - - // Gateway API Service - services.Add(new + return ExecuteAsync( + async () => { - Id = "core-api", - Name = "Gateway API", - Status = "healthy", - Uptime = GetProcessUptime(), - LastCheck = DateTime.UtcNow, - ResponseTime = 15, - Details = new + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var services = new List(); + + // Gateway API Service + services.Add(new { - Version = typeof(HealthMonitoringController).Assembly.GetName().Version?.ToString() ?? "unknown", - Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production", - RequestsHandled = await dbContext.RequestLogs - .CountAsync(r => r.Timestamp >= DateTime.UtcNow.AddHours(-1), cancellationToken) - } - }); + Id = "core-api", + Name = "Gateway API", + Status = "healthy", + Uptime = GetProcessUptime(), + LastCheck = DateTime.UtcNow, + ResponseTime = 15, + Details = new + { + Version = typeof(HealthMonitoringController).Assembly.GetName().Version?.ToString() ?? "unknown", + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production", + RequestsHandled = await dbContext.RequestLogs + .CountAsync(r => r.Timestamp >= DateTime.UtcNow.AddHours(-1), cancellationToken) + } + }); - // Admin API Service - services.Add(new - { - Id = "admin-api", - Name = "Admin API", - Status = "healthy", - Uptime = GetProcessUptime(), - LastCheck = DateTime.UtcNow, - ResponseTime = 10, - Details = new + // Admin API Service + services.Add(new { - ActiveSessions = 1, // Current session - ConfiguredKeys = await dbContext.VirtualKeys.CountAsync(cancellationToken) - } - }); + Id = "admin-api", + Name = "Admin API", + Status = "healthy", + Uptime = GetProcessUptime(), + LastCheck = DateTime.UtcNow, + ResponseTime = 10, + Details = new + { + ActiveSessions = 1, // Current session + ConfiguredKeys = await dbContext.VirtualKeys.CountAsync(cancellationToken) + } + }); - // Database Service - var dbHealthCheck = await CheckDatabaseHealth(dbContext, cancellationToken); - services.Add(new - { - Id = "database", - Name = "PostgreSQL Database", - Status = dbHealthCheck.IsHealthy ? "healthy" : "unhealthy", - Uptime = TimeSpan.FromDays(30), // Would need actual DB uptime - LastCheck = DateTime.UtcNow, - ResponseTime = dbHealthCheck.ResponseTime, - Details = new + // Database Service + var dbHealthCheck = await CheckDatabaseHealth(dbContext, cancellationToken); + services.Add(new { - ConnectionPooling = true, - ActiveConnections = 5, // Would need actual connection count - DatabaseSize = await GetDatabaseSize(dbContext, cancellationToken) - } - }); + Id = "database", + Name = "PostgreSQL Database", + Status = dbHealthCheck.IsHealthy ? "healthy" : "unhealthy", + Uptime = TimeSpan.FromDays(30), // Would need actual DB uptime + LastCheck = DateTime.UtcNow, + ResponseTime = dbHealthCheck.ResponseTime, + Details = new + { + ConnectionPooling = true, + ActiveConnections = 5, // Would need actual connection count + DatabaseSize = await GetDatabaseSize(dbContext, cancellationToken) + } + }); - // Calculate overall health - var healthyCount = services.Count(s => ((dynamic)s).Status == "healthy"); - var degradedCount = services.Count(s => ((dynamic)s).Status == "degraded"); - var unhealthyCount = services.Count(s => ((dynamic)s).Status == "unhealthy"); + // Calculate overall health + var healthyCount = services.Count(s => ((dynamic)s).Status == "healthy"); + var degradedCount = services.Count(s => ((dynamic)s).Status == "degraded"); + var unhealthyCount = services.Count(s => ((dynamic)s).Status == "unhealthy"); - return Ok(new - { - Timestamp = DateTime.UtcNow, - OverallStatus = unhealthyCount > 0 ? "unhealthy" : (degradedCount > 0 ? "degraded" : "healthy"), - Summary = new + return new { - Healthy = healthyCount, - Degraded = degradedCount, - Unhealthy = unhealthyCount, - Total = services.Count - }, - Services = services - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve service health"); - return StatusCode(500, new { error = "Failed to retrieve service health", message = ex.Message }); - } + Timestamp = DateTime.UtcNow, + OverallStatus = unhealthyCount > 0 ? "unhealthy" : (degradedCount > 0 ? "degraded" : "healthy"), + Summary = new + { + Healthy = healthyCount, + Degraded = degradedCount, + Unhealthy = unhealthyCount, + Total = services.Count + }, + Services = services + }; + }, + result => Ok(result), + "GetServiceHealth"); } /// @@ -135,87 +137,85 @@ public async Task GetServiceHealth(CancellationToken cancellation /// Cancellation token. /// Incident history data. [HttpGet("incidents")] - public async Task GetIncidents( + public Task GetIncidents( [FromQuery] int days = 7, CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var startDate = DateTime.UtcNow.AddDays(-days); + return ExecuteAsync( + async () => + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - // Analyze request logs for incidents - var errorSpikes = await dbContext.RequestLogs - .Where(r => r.Timestamp >= startDate && r.StatusCode >= 400) - .GroupBy(r => new - { - Date = r.Timestamp.Date, - Hour = r.Timestamp.Hour, - Model = r.ModelName - }) - .Select(g => new - { - Date = g.Key.Date, - Hour = g.Key.Hour, - Service = g.Key.Model, // Using ModelName as service identifier - ErrorCount = g.Count(), - ErrorTypes = g.Select(r => r.StatusCode).Distinct().Count() - }) - .Where(g => g.ErrorCount >= 10) // Threshold for incident - .ToListAsync(cancellationToken); + var startDate = DateTime.UtcNow.AddDays(-days); - // Convert to incidents - var incidents = errorSpikes.Select(spike => new - { - Id = Guid.NewGuid().ToString(), - Title = $"{spike.Service} Service Degradation", - Type = "service_degradation", - Severity = spike.ErrorCount >= 50 ? "critical" : (spike.ErrorCount >= 25 ? "major" : "minor"), - Status = spike.Date.Date == DateTime.UtcNow.Date ? "active" : "resolved", - StartTime = new DateTime(spike.Date.Year, spike.Date.Month, spike.Date.Day, spike.Hour, 0, 0), - EndTime = spike.Date.Date == DateTime.UtcNow.Date ? (DateTime?)null : - new DateTime(spike.Date.Year, spike.Date.Month, spike.Date.Day, spike.Hour, 59, 59), - AffectedService = spike.Service, - Impact = $"{spike.ErrorCount} errors in 1 hour period", - Details = new + // Analyze request logs for incidents + var errorSpikes = await dbContext.RequestLogs + .Where(r => r.Timestamp >= startDate && r.StatusCode >= 400) + .GroupBy(r => new + { + Date = r.Timestamp.Date, + Hour = r.Timestamp.Hour, + Model = r.ModelName + }) + .Select(g => new + { + Date = g.Key.Date, + Hour = g.Key.Hour, + Service = g.Key.Model, // Using ModelName as service identifier + ErrorCount = g.Count(), + ErrorTypes = g.Select(r => r.StatusCode).Distinct().Count() + }) + .Where(g => g.ErrorCount >= 10) // Threshold for incident + .ToListAsync(cancellationToken); + + // Convert to incidents + var incidents = errorSpikes.Select(spike => new { - ErrorCount = spike.ErrorCount, - UniqueErrorTypes = spike.ErrorTypes - } - }).ToList(); + Id = Guid.NewGuid().ToString(), + Title = $"{spike.Service} Service Degradation", + Type = "service_degradation", + Severity = spike.ErrorCount >= 50 ? "critical" : (spike.ErrorCount >= 25 ? "major" : "minor"), + Status = spike.Date.Date == DateTime.UtcNow.Date ? "active" : "resolved", + StartTime = new DateTime(spike.Date.Year, spike.Date.Month, spike.Date.Day, spike.Hour, 0, 0), + EndTime = spike.Date.Date == DateTime.UtcNow.Date ? (DateTime?)null : + new DateTime(spike.Date.Year, spike.Date.Month, spike.Date.Day, spike.Hour, 59, 59), + AffectedService = spike.Service, + Impact = $"{spike.ErrorCount} errors in 1 hour period", + Details = new + { + ErrorCount = spike.ErrorCount, + UniqueErrorTypes = spike.ErrorTypes + } + }).ToList(); - // Health failures removed - no longer tracking provider health + // Health failures removed - no longer tracking provider health - var allIncidents = incidents - .Cast() - .OrderByDescending(i => ((dynamic)i).StartTime) - .ToList(); + var allIncidents = incidents + .Cast() + .OrderByDescending(i => ((dynamic)i).StartTime) + .ToList(); - return Ok(new - { - Timestamp = DateTime.UtcNow, - TimeRange = new { Start = startDate, End = DateTime.UtcNow }, - TotalIncidents = allIncidents.Count, - ActiveIncidents = allIncidents.Count(i => ((dynamic)i).Status == "active"), - IncidentsByType = allIncidents.GroupBy(i => ((dynamic)i).Type).Select(g => new - { - Type = g.Key, - Count = g.Count() - }), - IncidentsBySeverity = allIncidents.GroupBy(i => ((dynamic)i).Severity).Select(g => new + return new { - Severity = g.Key, - Count = g.Count() - }), - Incidents = allIncidents - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve incidents"); - return StatusCode(500, new { error = "Failed to retrieve incidents", message = ex.Message }); - } + Timestamp = DateTime.UtcNow, + TimeRange = new { Start = startDate, End = DateTime.UtcNow }, + TotalIncidents = allIncidents.Count, + ActiveIncidents = allIncidents.Count(i => ((dynamic)i).Status == "active"), + IncidentsByType = allIncidents.GroupBy(i => ((dynamic)i).Type).Select(g => new + { + Type = g.Key, + Count = g.Count() + }), + IncidentsBySeverity = allIncidents.GroupBy(i => ((dynamic)i).Severity).Select(g => new + { + Severity = g.Key, + Count = g.Count() + }), + Incidents = allIncidents + }; + }, + result => Ok(result), + "GetIncidents"); } /// @@ -225,72 +225,70 @@ public async Task GetIncidents( /// Cancellation token. /// Health history time series. [HttpGet("history")] - public async Task GetHealthHistory( + public Task GetHealthHistory( [FromQuery] int hours = 24, CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var startTime = DateTime.UtcNow.AddHours(-hours); - var intervalMinutes = hours <= 24 ? 15 : 60; // 15 min intervals for 24h, 1h for longer - - var healthHistory = new List(); - var currentTime = startTime; - - while (currentTime < DateTime.UtcNow) + return ExecuteAsync( + async () => { - var intervalEnd = currentTime.AddMinutes(intervalMinutes); + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - // Provider health tracking has been removed + var startTime = DateTime.UtcNow.AddHours(-hours); + var intervalMinutes = hours <= 24 ? 15 : 60; // 15 min intervals for 24h, 1h for longer - // Get error rates for this interval - var errorStats = await dbContext.RequestLogs - .Where(r => r.Timestamp >= currentTime && r.Timestamp < intervalEnd) - .GroupBy(r => 1) - .Select(g => new - { - TotalRequests = g.Count(), - ErrorCount = g.Count(r => r.StatusCode >= 400), - AvgLatency = g.Average(r => (double?)r.ResponseTimeMs) ?? 0 - }) - .FirstOrDefaultAsync(cancellationToken); + var healthHistory = new List(); + var currentTime = startTime; - healthHistory.Add(new + while (currentTime < DateTime.UtcNow) { - Timestamp = currentTime, - SystemHealth = errorStats?.TotalRequests > 0 - ? 100 - (errorStats.ErrorCount * 100.0 / errorStats.TotalRequests) - : 100, - ProviderHealth = 100, // Provider health tracking removed - ResponseTime = errorStats?.AvgLatency ?? 0, - RequestVolume = errorStats?.TotalRequests ?? 0, - ErrorRate = errorStats?.TotalRequests > 0 - ? errorStats.ErrorCount * 100.0 / errorStats.TotalRequests - : 0 - }); + var intervalEnd = currentTime.AddMinutes(intervalMinutes); - currentTime = intervalEnd; - } + // Provider health tracking has been removed - return Ok(new - { - Timestamp = DateTime.UtcNow, - TimeRange = new { Start = startTime, End = DateTime.UtcNow }, - IntervalMinutes = intervalMinutes, - History = healthHistory - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve health history"); - return StatusCode(500, new { error = "Failed to retrieve health history", message = ex.Message }); - } + // Get error rates for this interval + var errorStats = await dbContext.RequestLogs + .Where(r => r.Timestamp >= currentTime && r.Timestamp < intervalEnd) + .GroupBy(r => 1) + .Select(g => new + { + TotalRequests = g.Count(), + ErrorCount = g.Count(r => r.StatusCode >= 400), + AvgLatency = g.Average(r => (double?)r.ResponseTimeMs) ?? 0 + }) + .FirstOrDefaultAsync(cancellationToken); + + healthHistory.Add(new + { + Timestamp = currentTime, + SystemHealth = errorStats?.TotalRequests > 0 + ? 100 - (errorStats.ErrorCount * 100.0 / errorStats.TotalRequests) + : 100, + ProviderHealth = 100, // Provider health tracking removed + ResponseTime = errorStats?.AvgLatency ?? 0, + RequestVolume = errorStats?.TotalRequests ?? 0, + ErrorRate = errorStats?.TotalRequests > 0 + ? errorStats.ErrorCount * 100.0 / errorStats.TotalRequests + : 0 + }); + + currentTime = intervalEnd; + } + + return new + { + Timestamp = DateTime.UtcNow, + TimeRange = new { Start = startTime, End = DateTime.UtcNow }, + IntervalMinutes = intervalMinutes, + History = healthHistory + }; + }, + result => Ok(result), + "GetHealthHistory"); } private async Task<(bool IsHealthy, int ResponseTime)> CheckDatabaseHealth( - ConduitDbContext dbContext, + ConduitDbContext dbContext, CancellationToken cancellationToken) { try @@ -310,10 +308,9 @@ private async Task GetDatabaseSize(ConduitDbContext dbContext, Cancellat { try { - // This would vary by database provider - // Added to ensure the method remains asynchronous and to avoid CS1998 warning - await Task.CompletedTask; - return "Unknown"; + var systemInfo = await _systemInfoService.GetSystemInfoAsync(); + var size = systemInfo.Database.Size; + return !string.IsNullOrEmpty(size) ? size : "Unknown"; } catch { diff --git a/Services/ConduitLLM.Admin/Controllers/IpFilterController.cs b/Services/ConduitLLM.Admin/Controllers/IpFilterController.cs index d11b59189..ff1709913 100644 --- a/Services/ConduitLLM.Admin/Controllers/IpFilterController.cs +++ b/Services/ConduitLLM.Admin/Controllers/IpFilterController.cs @@ -13,10 +13,9 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class IpFilterController : ControllerBase +public class IpFilterController : AdminControllerBase { private readonly IAdminIpFilterService _ipFilterService; - private readonly ILogger _logger; /// /// Initializes a new instance of the IpFilterController @@ -26,9 +25,9 @@ public class IpFilterController : ControllerBase public IpFilterController( IAdminIpFilterService ipFilterService, ILogger logger) + : base(logger) { _ipFilterService = ipFilterService ?? throw new ArgumentNullException(nameof(ipFilterService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -38,18 +37,12 @@ public IpFilterController( [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllFilters() + public Task GetAllFilters() { - try - { - var filters = await _ipFilterService.GetAllFiltersAsync(); - return Ok(filters); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all IP filters"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _ipFilterService.GetAllFiltersAsync(), + Ok, + "GetAllFilters"); } /// @@ -59,18 +52,12 @@ public async Task GetAllFilters() [HttpGet("enabled")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetEnabledFilters() + public Task GetEnabledFilters() { - try - { - var filters = await _ipFilterService.GetEnabledFiltersAsync(); - return Ok(filters); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting enabled IP filters"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _ipFilterService.GetEnabledFiltersAsync(), + Ok, + "GetEnabledFilters"); } /// @@ -82,24 +69,14 @@ public async Task GetEnabledFilters() [ProducesResponseType(typeof(IpFilterDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetFilterById(int id) + public Task GetFilterById(int id) { - try - { - var filter = await _ipFilterService.GetFilterByIdAsync(id); - - if (filter == null) - { - return NotFound("IP filter not found"); - } - - return Ok(filter); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting IP filter with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _ipFilterService.GetFilterByIdAsync(id), + Ok, + "IP filter", + id, + "GetFilterById"); } /// @@ -114,29 +91,26 @@ public async Task GetFilterById(int id) [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateFilter([FromBody] CreateIpFilterDto filter) + public Task CreateFilter([FromBody] CreateIpFilterDto filter) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } + return ExecuteAsync( + async () => + { + var (success, errorMessage, createdFilter) = await _ipFilterService.CreateFilterAsync(filter); - try - { - var (success, errorMessage, createdFilter) = await _ipFilterService.CreateFilterAsync(filter); + if (!success) + { + throw new InvalidOperationException(errorMessage); + } - if (!success) + return createdFilter!; + }, + createdFilter => { - return BadRequest(errorMessage); - } - - return CreatedAtAction(nameof(GetFilterById), new { id = createdFilter!.Id }, createdFilter); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating IP filter"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + LogAdminAudit("Created", "IpFilter", createdFilter.Id, $"CIDR: {LoggingSanitizer.S(filter.IpAddressOrCidr)}, Type: {filter.FilterType}"); + return CreatedAtAction(nameof(GetFilterById), new { id = createdFilter.Id }, createdFilter); + }, + "CreateFilter"); } /// @@ -153,40 +127,34 @@ public async Task CreateFilter([FromBody] CreateIpFilterDto filte [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateFilter(int id, [FromBody] UpdateIpFilterDto filter) + public Task UpdateFilter(int id, [FromBody] UpdateIpFilterDto filter) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - // Ensure ID in route matches ID in body if (id != filter.Id) { - return BadRequest("ID in route must match ID in body"); + return Task.FromResult(BadRequest("ID in route must match ID in body")); } - try - { - var (success, errorMessage) = await _ipFilterService.UpdateFilterAsync(filter); - - if (!success) + return ExecuteAsync( + async () => { - if (errorMessage?.Contains("not found") == true) + var (success, errorMessage) = await _ipFilterService.UpdateFilterAsync(filter); + + if (!success) { - return NotFound(errorMessage); - } + if (errorMessage?.Contains("not found") == true) + { + throw new KeyNotFoundException(errorMessage); + } - return BadRequest(errorMessage); - } + throw new InvalidOperationException(errorMessage); + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating IP filter with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + LogAdminAudit("Updated", "IpFilter", id, $"CIDR: {LoggingSanitizer.S(filter.IpAddressOrCidr)}, Type: {filter.FilterType}"); + }, + NoContent(), + "UpdateFilter", + new { Id = id }); } /// @@ -201,29 +169,28 @@ public async Task UpdateFilter(int id, [FromBody] UpdateIpFilterD [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteFilter(int id) + public Task DeleteFilter(int id) { - try - { - var (success, errorMessage) = await _ipFilterService.DeleteFilterAsync(id); - - if (!success) + return ExecuteAsync( + async () => { - if (errorMessage?.Contains("not found") == true) + var (success, errorMessage) = await _ipFilterService.DeleteFilterAsync(id); + + if (!success) { - return NotFound(errorMessage); - } + if (errorMessage?.Contains("not found") == true) + { + throw new KeyNotFoundException(errorMessage); + } - return BadRequest(errorMessage); - } + throw new InvalidOperationException(errorMessage); + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting IP filter with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + LogAdminAudit("Deleted", "IpFilter", id, $"Id: {id}"); + }, + NoContent(), + "DeleteFilter", + new { Id = id }); } /// @@ -233,18 +200,12 @@ public async Task DeleteFilter(int id) [HttpGet("settings")] [ProducesResponseType(typeof(IpFilterSettingsDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetSettings() + public Task GetSettings() { - try - { - var settings = await _ipFilterService.GetIpFilterSettingsAsync(); - return Ok(settings); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting IP filter settings"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _ipFilterService.GetIpFilterSettingsAsync(), + Ok, + "GetSettings"); } /// @@ -259,29 +220,22 @@ public async Task GetSettings() [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateSettings([FromBody] IpFilterSettingsDto settings) + public Task UpdateSettings([FromBody] IpFilterSettingsDto settings) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var (success, errorMessage) = await _ipFilterService.UpdateIpFilterSettingsAsync(settings); - - if (!success) + return ExecuteAsync( + async () => { - return BadRequest(errorMessage); - } + var (success, errorMessage) = await _ipFilterService.UpdateIpFilterSettingsAsync(settings); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating IP filter settings"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!success) + { + throw new InvalidOperationException(errorMessage); + } + + LogAdminAudit("Updated", "IpFilterSettings", detail: $"Enabled: {settings.IsEnabled}, DefaultAllow: {settings.DefaultAllow}"); + }, + NoContent(), + "UpdateSettings"); } /// @@ -294,22 +248,17 @@ public async Task UpdateSettings([FromBody] IpFilterSettingsDto s [ProducesResponseType(typeof(IpCheckResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CheckIpAddress(string ipAddress) + public Task CheckIpAddress(string ipAddress) { if (string.IsNullOrWhiteSpace(ipAddress)) { - return BadRequest("IP address must be provided"); + return Task.FromResult(BadRequest("IP address must be provided")); } - try - { - var result = await _ipFilterService.CheckIpAddressAsync(ipAddress); - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking IP address {IpAddress}", LoggingSanitizer.S(ipAddress)); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _ipFilterService.CheckIpAddressAsync(ipAddress), + Ok, + "CheckIpAddress", + new { IpAddress = LoggingSanitizer.S(ipAddress) }); } } diff --git a/Services/ConduitLLM.Admin/Controllers/MediaCleanupController.cs b/Services/ConduitLLM.Admin/Controllers/MediaCleanupController.cs deleted file mode 100644 index ba81223fa..000000000 --- a/Services/ConduitLLM.Admin/Controllers/MediaCleanupController.cs +++ /dev/null @@ -1,187 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using ConduitLLM.Admin.DTOs; -using ConduitLLM.Admin.Interfaces; - -namespace ConduitLLM.Admin.Controllers -{ - /// - /// Controller for media cleanup service status and management. - /// Provides operational visibility into cleanup runs, budget usage, and configuration. - /// - [ApiController] - [Route("api/admin/media-cleanup")] - [Authorize(Policy = "MasterKeyPolicy")] - public class MediaCleanupController : ControllerBase - { - private readonly IMediaCleanupStatusService _statusService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - public MediaCleanupController( - IMediaCleanupStatusService statusService, - ILogger logger) - { - _statusService = statusService; - _logger = logger; - } - - /// - /// Gets the current status of the media cleanup service. - /// - /// Status information including last run, budget usage, and retention policies. - [HttpGet("status")] - [ProducesResponseType(typeof(MediaCleanupStatusDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetStatus() - { - try - { - var status = await _statusService.GetStatusAsync(); - return Ok(status); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting media cleanup status"); - return StatusCode( - StatusCodes.Status500InternalServerError, - new { message = "An error occurred while getting cleanup status" }); - } - } - - /// - /// Gets whether the media cleanup service is currently enabled. - /// - /// The enabled state. - [HttpGet("enabled")] - [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetEnabled() - { - try - { - var isEnabled = await _statusService.IsEnabledAsync(); - return Ok(new { enabled = isEnabled }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting media cleanup enabled state"); - return StatusCode( - StatusCodes.Status500InternalServerError, - new { message = "An error occurred while getting enabled state" }); - } - } - - /// - /// Enables or disables the media cleanup service at runtime. - /// This setting persists across restarts via GlobalSettings. - /// - /// The enabled state to set. - /// The new enabled state. - [HttpPost("enabled")] - [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task SetEnabled([FromBody] UpdateMediaCleanupEnabledRequest request) - { - try - { - await _statusService.SetEnabledAsync(request.Enabled); - - _logger.LogInformation( - "Media cleanup service enabled state changed to {Enabled} by admin request", - request.Enabled); - - return Ok(new - { - enabled = request.Enabled, - message = request.Enabled - ? "Media cleanup service has been enabled" - : "Media cleanup service has been disabled" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error setting media cleanup enabled state"); - return StatusCode( - StatusCodes.Status500InternalServerError, - new { message = "An error occurred while setting enabled state" }); - } - } - - /// - /// Gets the simple retention override setting. - /// When active, all media uses this retention period regardless of account balance. - /// - /// The current simple retention override, or null if using policy-based retention. - [HttpGet("simple-retention")] - [ProducesResponseType(typeof(SimpleRetentionResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetSimpleRetention() - { - try - { - var days = await _statusService.GetSimpleRetentionOverrideAsync(); - return Ok(new SimpleRetentionResponse - { - RetentionDays = days, - IsOverrideActive = days.HasValue - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting simple retention override"); - return StatusCode( - StatusCodes.Status500InternalServerError, - new { message = "An error occurred while getting simple retention override" }); - } - } - - /// - /// Sets or clears the simple retention override. - /// When set, all media is deleted after the specified number of days regardless of account balance. - /// Pass null for RetentionDays to clear the override and use policy-based retention. - /// - /// The retention days to set (1-365), or null to clear. - /// The new simple retention override state. - [HttpPost("simple-retention")] - [ProducesResponseType(typeof(SimpleRetentionResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task SetSimpleRetention([FromBody] UpdateSimpleRetentionRequest request) - { - try - { - await _statusService.SetSimpleRetentionOverrideAsync(request.RetentionDays); - - var message = request.RetentionDays.HasValue - ? $"Simple retention override set to {request.RetentionDays} days - all media will be deleted after this period" - : "Simple retention override cleared - using policy-based retention"; - - _logger.LogInformation( - "Simple retention override changed to {Days} by admin request", - request.RetentionDays?.ToString() ?? "null (cleared)"); - - return Ok(new SimpleRetentionResponse - { - RetentionDays = request.RetentionDays, - IsOverrideActive = request.RetentionDays.HasValue, - Message = message - }); - } - catch (ArgumentOutOfRangeException ex) - { - return BadRequest(new { message = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error setting simple retention override"); - return StatusCode( - StatusCodes.Status500InternalServerError, - new { message = "An error occurred while setting simple retention override" }); - } - } - } -} diff --git a/Services/ConduitLLM.Admin/Controllers/MediaController.cs b/Services/ConduitLLM.Admin/Controllers/MediaController.cs index 93698fe61..877069a6b 100644 --- a/Services/ConduitLLM.Admin/Controllers/MediaController.cs +++ b/Services/ConduitLLM.Admin/Controllers/MediaController.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Admin.DTOs; using ConduitLLM.Admin.Interfaces; using Microsoft.AspNetCore.Authorization; using ConduitLLM.Configuration.DTOs; @@ -6,27 +7,31 @@ namespace ConduitLLM.Admin.Controllers { /// - /// Administrative controller for media lifecycle management. + /// Administrative controller for media lifecycle management including + /// statistics, search, cleanup operations, and cleanup service configuration. /// [ApiController] [Route("api/admin/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class MediaController : ControllerBase + public class MediaController : AdminControllerBase { private readonly IAdminMediaService _mediaService; - private readonly ILogger _logger; + private readonly IMediaCleanupStatusService _cleanupStatusService; /// /// Initializes a new instance of the MediaController class. /// /// The admin media service. + /// The media cleanup status service. /// The logger instance. public MediaController( IAdminMediaService mediaService, + IMediaCleanupStatusService cleanupStatusService, ILogger logger) + : base(logger) { _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _cleanupStatusService = cleanupStatusService ?? throw new ArgumentNullException(nameof(cleanupStatusService)); } /// @@ -35,18 +40,13 @@ public MediaController( /// Optional filter by virtual key group ID /// Overall storage statistics. [HttpGet("stats")] - public async Task GetOverallStats([FromQuery] int? virtualKeyGroupId = null) + public Task GetOverallStats([FromQuery] int? virtualKeyGroupId = null) { - try - { - var stats = await _mediaService.GetOverallStorageStatsAsync(virtualKeyGroupId); - return Ok(stats); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting overall storage statistics"); - return StatusCode(500, new ErrorResponseDto("Failed to get storage statistics")); - } + return ExecuteAsync( + () => _mediaService.GetOverallStorageStatsAsync(virtualKeyGroupId), + Ok, + "GetOverallStats", + new { VirtualKeyGroupId = virtualKeyGroupId }); } /// @@ -55,18 +55,13 @@ public async Task GetOverallStats([FromQuery] int? virtualKeyGrou /// The ID of the virtual key. /// Storage statistics for the virtual key. [HttpGet("stats/virtual-key/{virtualKeyId}")] - public async Task GetStatsByVirtualKey(int virtualKeyId) + public Task GetStatsByVirtualKey(int virtualKeyId) { - try - { - var stats = await _mediaService.GetStorageStatsByVirtualKeyAsync(virtualKeyId); - return Ok(stats); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting storage statistics for virtual key {VirtualKeyId}", virtualKeyId); - return StatusCode(500, new ErrorResponseDto("Failed to get storage statistics")); - } + return ExecuteAsync( + () => _mediaService.GetStorageStatsByVirtualKeyAsync(virtualKeyId), + Ok, + "GetStatsByVirtualKey", + new { VirtualKeyId = virtualKeyId }); } /// @@ -74,18 +69,12 @@ public async Task GetStatsByVirtualKey(int virtualKeyId) /// /// Dictionary of provider names to storage size. [HttpGet("stats/by-provider")] - public async Task GetStatsByProvider() + public Task GetStatsByProvider() { - try - { - var stats = await _mediaService.GetStorageStatsByProviderAsync(); - return Ok(stats); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting storage statistics by provider"); - return StatusCode(500, new ErrorResponseDto("Failed to get storage statistics")); - } + return ExecuteAsync( + () => _mediaService.GetStorageStatsByProviderAsync(), + Ok, + "GetStatsByProvider"); } /// @@ -93,18 +82,12 @@ public async Task GetStatsByProvider() /// /// Dictionary of media types to storage size. [HttpGet("stats/by-type")] - public async Task GetStatsByMediaType() + public Task GetStatsByMediaType() { - try - { - var stats = await _mediaService.GetStorageStatsByMediaTypeAsync(); - return Ok(stats); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting storage statistics by media type"); - return StatusCode(500, new ErrorResponseDto("Failed to get storage statistics")); - } + return ExecuteAsync( + () => _mediaService.GetStorageStatsByMediaTypeAsync(), + Ok, + "GetStatsByMediaType"); } /// @@ -113,18 +96,13 @@ public async Task GetStatsByMediaType() /// The ID of the virtual key. /// List of media records. [HttpGet("virtual-key/{virtualKeyId}")] - public async Task GetMediaByVirtualKey(int virtualKeyId) + public Task GetMediaByVirtualKey(int virtualKeyId) { - try - { - var media = await _mediaService.GetMediaByVirtualKeyAsync(virtualKeyId); - return Ok(media); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting media for virtual key {VirtualKeyId}", virtualKeyId); - return StatusCode(500, new ErrorResponseDto("Failed to get media records")); - } + return ExecuteAsync( + () => _mediaService.GetMediaByVirtualKeyAsync(virtualKeyId), + Ok, + "GetMediaByVirtualKey", + new { VirtualKeyId = virtualKeyId }); } /// @@ -133,23 +111,18 @@ public async Task GetMediaByVirtualKey(int virtualKeyId) /// The pattern to search for in storage keys. /// List of matching media records. [HttpGet("search")] - public async Task SearchMedia([FromQuery] string pattern) + public Task SearchMedia([FromQuery] string pattern) { - try - { - if (string.IsNullOrWhiteSpace(pattern)) - { - return BadRequest(new ErrorResponseDto("Search pattern is required")); - } - - var media = await _mediaService.SearchMediaByStorageKeyAsync(pattern); - return Ok(media); - } - catch (Exception ex) + if (string.IsNullOrWhiteSpace(pattern)) { - _logger.LogError(ex, "Error searching media with pattern {Pattern}", pattern); - return StatusCode(500, new ErrorResponseDto("Failed to search media")); + return Task.FromResult(BadRequest(new ErrorResponseDto("Search pattern is required"))); } + + return ExecuteAsync( + () => _mediaService.SearchMediaByStorageKeyAsync(pattern), + Ok, + "SearchMedia", + new { Pattern = pattern }); } /// @@ -158,23 +131,18 @@ public async Task SearchMedia([FromQuery] string pattern) /// The ID of the media record to delete. /// Success status. [HttpDelete("{mediaId}")] - public async Task DeleteMedia(Guid mediaId) + public Task DeleteMedia(Guid mediaId) { - try - { - var result = await _mediaService.DeleteMediaAsync(mediaId); - if (!result) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Media record not found")); - } - - return Ok(new { message = "Media deleted successfully" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting media {MediaId}", mediaId); - return StatusCode(500, new ErrorResponseDto("Failed to delete media")); - } + if (!await _mediaService.DeleteMediaAsync(mediaId)) + throw new KeyNotFoundException(); + LogAdminAudit("Deleted", "Media", mediaId); + }, + Ok(new { message = "Media deleted successfully" }), + "DeleteMedia", + new { MediaId = mediaId }); } /// @@ -182,21 +150,21 @@ public async Task DeleteMedia(Guid mediaId) /// /// Number of files cleaned up. [HttpPost("cleanup/expired")] - public async Task CleanupExpiredMedia() + public Task CleanupExpiredMedia() { - try - { - var count = await _mediaService.CleanupExpiredMediaAsync(); - return Ok(new { - message = $"Cleaned up {count} expired media files", - deletedCount = count - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during expired media cleanup"); - return StatusCode(500, new ErrorResponseDto("Failed to cleanup expired media")); - } + return ExecuteAsync( + async () => + { + var count = await _mediaService.CleanupExpiredMediaAsync(); + LogAdminAudit("CleanedUpExpired", "Media", detail: $"DeletedCount: {count}"); + return (object)new + { + message = $"Cleaned up {count} expired media files", + deletedCount = count + }; + }, + Ok, + "CleanupExpiredMedia"); } /// @@ -204,21 +172,21 @@ public async Task CleanupExpiredMedia() /// /// Number of files cleaned up. [HttpPost("cleanup/orphaned")] - public async Task CleanupOrphanedMedia() + public Task CleanupOrphanedMedia() { - try - { - var count = await _mediaService.CleanupOrphanedMediaAsync(); - return Ok(new { - message = $"Cleaned up {count} orphaned media files", - deletedCount = count - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during orphaned media cleanup"); - return StatusCode(500, new ErrorResponseDto("Failed to cleanup orphaned media")); - } + return ExecuteAsync( + async () => + { + var count = await _mediaService.CleanupOrphanedMediaAsync(); + LogAdminAudit("CleanedUpOrphaned", "Media", detail: $"DeletedCount: {count}"); + return (object)new + { + message = $"Cleaned up {count} orphaned media files", + deletedCount = count + }; + }, + Ok, + "CleanupOrphanedMedia"); } /// @@ -227,26 +195,132 @@ public async Task CleanupOrphanedMedia() /// The pruning request with days to keep. /// Number of files pruned. [HttpPost("cleanup/prune")] - public async Task PruneOldMedia([FromBody] PruneMediaRequest request) + public Task PruneOldMedia([FromBody] PruneMediaRequest request) { - try + if (request?.DaysToKeep == null || request.DaysToKeep <= 0) { - if (request?.DaysToKeep == null || request.DaysToKeep <= 0) + return Task.FromResult(BadRequest(new ErrorResponseDto("DaysToKeep must be a positive number"))); + } + + return ExecuteAsync( + async () => { - return BadRequest(new ErrorResponseDto("DaysToKeep must be a positive number")); - } + var count = await _mediaService.PruneOldMediaAsync(request.DaysToKeep.Value); + LogAdminAudit("Pruned", "Media", detail: $"DaysToKeep: {request.DaysToKeep}, DeletedCount: {count}"); + return (object)new + { + message = $"Pruned {count} media files older than {request.DaysToKeep} days", + deletedCount = count + }; + }, + Ok, + "PruneOldMedia", + new { DaysToKeep = request?.DaysToKeep }); + } - var count = await _mediaService.PruneOldMediaAsync(request.DaysToKeep.Value); - return Ok(new { - message = $"Pruned {count} media files older than {request.DaysToKeep} days", - deletedCount = count - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during old media pruning"); - return StatusCode(500, new ErrorResponseDto("Failed to prune old media")); - } + // โ”€โ”€โ”€ Cleanup Service Configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Routes use absolute paths to maintain backward compatibility with api/admin/media-cleanup + + /// + /// Gets the current status of the media cleanup service. + /// + [HttpGet("/api/admin/media-cleanup/status")] + [ProducesResponseType(typeof(MediaCleanupStatusDto), StatusCodes.Status200OK)] + public Task GetCleanupStatus() + { + return ExecuteAsync( + () => _cleanupStatusService.GetStatusAsync(), + Ok, + "GetCleanupStatus"); + } + + /// + /// Gets whether the media cleanup service is currently enabled. + /// + [HttpGet("/api/admin/media-cleanup/enabled")] + public Task GetCleanupEnabled() + { + return ExecuteAsync( + async () => + { + var isEnabled = await _cleanupStatusService.IsEnabledAsync(); + return new { enabled = isEnabled }; + }, + Ok, + "GetCleanupEnabled"); + } + + /// + /// Enables or disables the media cleanup service at runtime. + /// This setting persists across restarts via GlobalSettings. + /// + [HttpPost("/api/admin/media-cleanup/enabled")] + public Task SetCleanupEnabled([FromBody] UpdateMediaCleanupEnabledRequest request) + { + return ExecuteAsync( + async () => + { + await _cleanupStatusService.SetEnabledAsync(request.Enabled); + LogAdminAudit("SetEnabled", "MediaCleanupService", detail: $"Enabled: {request.Enabled}"); + return new + { + enabled = request.Enabled, + message = request.Enabled + ? "Media cleanup service has been enabled" + : "Media cleanup service has been disabled" + }; + }, + Ok, + "SetCleanupEnabled"); + } + + /// + /// Gets the simple retention override setting. + /// + [HttpGet("/api/admin/media-cleanup/simple-retention")] + [ProducesResponseType(typeof(SimpleRetentionResponse), StatusCodes.Status200OK)] + public Task GetSimpleRetention() + { + return ExecuteAsync( + async () => + { + var days = await _cleanupStatusService.GetSimpleRetentionOverrideAsync(); + return new SimpleRetentionResponse + { + RetentionDays = days, + IsOverrideActive = days.HasValue + }; + }, + Ok, + "GetSimpleRetention"); + } + + /// + /// Sets or clears the simple retention override. + /// Pass null for RetentionDays to clear the override and use policy-based retention. + /// + [HttpPost("/api/admin/media-cleanup/simple-retention")] + [ProducesResponseType(typeof(SimpleRetentionResponse), StatusCodes.Status200OK)] + public Task SetSimpleRetention([FromBody] UpdateSimpleRetentionRequest request) + { + return ExecuteAsync( + async () => + { + await _cleanupStatusService.SetSimpleRetentionOverrideAsync(request.RetentionDays); + var message = request.RetentionDays.HasValue + ? $"Simple retention override set to {request.RetentionDays} days - all media will be deleted after this period" + : "Simple retention override cleared - using policy-based retention"; + LogAdminAudit("SetSimpleRetention", "MediaCleanupService", + detail: $"RetentionDays: {request.RetentionDays?.ToString() ?? "cleared"}"); + return new SimpleRetentionResponse + { + RetentionDays = request.RetentionDays, + IsOverrideActive = request.RetentionDays.HasValue, + Message = message + }; + }, + Ok, + "SetSimpleRetention"); } } diff --git a/Services/ConduitLLM.Admin/Controllers/MediaRetentionController.cs b/Services/ConduitLLM.Admin/Controllers/MediaRetentionController.cs index b62569c21..a650c6cf3 100644 --- a/Services/ConduitLLM.Admin/Controllers/MediaRetentionController.cs +++ b/Services/ConduitLLM.Admin/Controllers/MediaRetentionController.cs @@ -1,3 +1,6 @@ +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Core.Extensions; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -12,10 +15,9 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/admin/media-retention")] [Authorize(Policy = "MasterKeyPolicy")] - public class MediaRetentionController : ControllerBase + public class MediaRetentionController : AdminControllerBase { private readonly IConfigurationDbContext _context; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -25,9 +27,9 @@ public class MediaRetentionController : ControllerBase public MediaRetentionController( IConfigurationDbContext context, ILogger logger) + : base(logger) { _context = context; - _logger = logger; } /// @@ -36,33 +38,34 @@ public MediaRetentionController( /// List of all retention policies [HttpGet("policies")] [ProducesResponseType(typeof(List), 200)] - public async Task GetPolicies() + public Task GetPolicies() { - var policies = await _context.MediaRetentionPolicies - .Include(p => p.VirtualKeyGroups) - .OrderBy(p => p.Name) - .Select(p => new MediaRetentionPolicyDto - { - Id = p.Id, - Name = p.Name, - Description = p.Description, - PositiveBalanceRetentionDays = p.PositiveBalanceRetentionDays, - ZeroBalanceRetentionDays = p.ZeroBalanceRetentionDays, - NegativeBalanceRetentionDays = p.NegativeBalanceRetentionDays, - SoftDeleteGracePeriodDays = p.SoftDeleteGracePeriodDays, - RespectRecentAccess = p.RespectRecentAccess, - RecentAccessWindowDays = p.RecentAccessWindowDays, - IsDefault = p.IsDefault, - MaxStorageSizeBytes = p.MaxStorageSizeBytes, - MaxFileCount = p.MaxFileCount, - IsActive = p.IsActive, - CreatedAt = p.CreatedAt, - UpdatedAt = p.UpdatedAt, - VirtualKeyGroupCount = p.VirtualKeyGroups.Count - }) - .ToListAsync(); - - return Ok(policies); + return ExecuteAsync( + async () => await _context.MediaRetentionPolicies + .Include(p => p.VirtualKeyGroups) + .OrderBy(p => p.Name) + .Select(p => new MediaRetentionPolicyDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + PositiveBalanceRetentionDays = p.PositiveBalanceRetentionDays, + ZeroBalanceRetentionDays = p.ZeroBalanceRetentionDays, + NegativeBalanceRetentionDays = p.NegativeBalanceRetentionDays, + SoftDeleteGracePeriodDays = p.SoftDeleteGracePeriodDays, + RespectRecentAccess = p.RespectRecentAccess, + RecentAccessWindowDays = p.RecentAccessWindowDays, + IsDefault = p.IsDefault, + MaxStorageSizeBytes = p.MaxStorageSizeBytes, + MaxFileCount = p.MaxFileCount, + IsActive = p.IsActive, + CreatedAt = p.CreatedAt, + UpdatedAt = p.UpdatedAt, + VirtualKeyGroupCount = p.VirtualKeyGroups.Count + }) + .ToListAsync(), + policies => Ok(policies), + nameof(GetPolicies)); } /// @@ -73,44 +76,38 @@ public async Task GetPolicies() [HttpGet("policies/{id}")] [ProducesResponseType(typeof(MediaRetentionPolicyDetailDto), 200)] [ProducesResponseType(404)] - public async Task GetPolicy(int id) + public Task GetPolicy(int id) { - var policy = await _context.MediaRetentionPolicies - .Include(p => p.VirtualKeyGroups) - .ThenInclude(vkg => vkg.VirtualKeys) - .FirstOrDefaultAsync(p => p.Id == id); - - if (policy == null) - { - return NotFound(new { message = $"Policy with ID {id} not found" }); - } - - var dto = new MediaRetentionPolicyDetailDto - { - Id = policy.Id, - Name = policy.Name, - Description = policy.Description, - PositiveBalanceRetentionDays = policy.PositiveBalanceRetentionDays, - ZeroBalanceRetentionDays = policy.ZeroBalanceRetentionDays, - NegativeBalanceRetentionDays = policy.NegativeBalanceRetentionDays, - SoftDeleteGracePeriodDays = policy.SoftDeleteGracePeriodDays, - RespectRecentAccess = policy.RespectRecentAccess, - RecentAccessWindowDays = policy.RecentAccessWindowDays, - IsDefault = policy.IsDefault, - MaxStorageSizeBytes = policy.MaxStorageSizeBytes, - MaxFileCount = policy.MaxFileCount, - IsActive = policy.IsActive, - CreatedAt = policy.CreatedAt, - UpdatedAt = policy.UpdatedAt, - VirtualKeyGroups = policy.VirtualKeyGroups.Select(vkg => new VirtualKeyGroupSummaryDto + return ExecuteWithNotFoundAsync( + () => _context.MediaRetentionPolicies + .Include(p => p.VirtualKeyGroups) + .ThenInclude(vkg => vkg.VirtualKeys) + .FirstOrDefaultAsync(p => p.Id == id), + policy => Ok(new MediaRetentionPolicyDetailDto { - Id = vkg.Id, - Balance = vkg.Balance, - VirtualKeyCount = vkg.VirtualKeys.Count - }).ToList() - }; - - return Ok(dto); + Id = policy.Id, + Name = policy.Name, + Description = policy.Description, + PositiveBalanceRetentionDays = policy.PositiveBalanceRetentionDays, + ZeroBalanceRetentionDays = policy.ZeroBalanceRetentionDays, + NegativeBalanceRetentionDays = policy.NegativeBalanceRetentionDays, + SoftDeleteGracePeriodDays = policy.SoftDeleteGracePeriodDays, + RespectRecentAccess = policy.RespectRecentAccess, + RecentAccessWindowDays = policy.RecentAccessWindowDays, + IsDefault = policy.IsDefault, + MaxStorageSizeBytes = policy.MaxStorageSizeBytes, + MaxFileCount = policy.MaxFileCount, + IsActive = policy.IsActive, + CreatedAt = policy.CreatedAt, + UpdatedAt = policy.UpdatedAt, + VirtualKeyGroups = policy.VirtualKeyGroups.Select(vkg => new VirtualKeyGroupSummaryDto + { + Id = vkg.Id, + Balance = vkg.Balance, + VirtualKeyCount = vkg.VirtualKeys.Count + }).ToList() + }), + "Retention policy", id, nameof(GetPolicy)); } /// @@ -121,67 +118,73 @@ public async Task GetPolicy(int id) [HttpPost("policies")] [ProducesResponseType(typeof(MediaRetentionPolicyDto), 201)] [ProducesResponseType(400)] - public async Task CreatePolicy([FromBody] CreateMediaRetentionPolicyRequest request) + public Task CreatePolicy([FromBody] CreateMediaRetentionPolicyRequest request) { - // Validate request if (request.PositiveBalanceRetentionDays <= 0) { - return BadRequest(new { message = "Positive balance retention days must be greater than 0" }); + return Task.FromResult( + this.BadRequestError("Positive balance retention days must be greater than 0")); } - if (request.IsDefault) - { - // Ensure only one default policy exists - var existingDefault = await _context.MediaRetentionPolicies - .FirstOrDefaultAsync(p => p.IsDefault); - if (existingDefault != null) + return ExecuteAsync( + async () => { - existingDefault.IsDefault = false; - } - } - - var policy = new MediaRetentionPolicy - { - Name = request.Name, - Description = request.Description, - PositiveBalanceRetentionDays = request.PositiveBalanceRetentionDays, - ZeroBalanceRetentionDays = request.ZeroBalanceRetentionDays, - NegativeBalanceRetentionDays = request.NegativeBalanceRetentionDays, - SoftDeleteGracePeriodDays = request.SoftDeleteGracePeriodDays, - RespectRecentAccess = request.RespectRecentAccess, - RecentAccessWindowDays = request.RecentAccessWindowDays, - IsDefault = request.IsDefault, - MaxStorageSizeBytes = request.MaxStorageSizeBytes, - MaxFileCount = request.MaxFileCount, - IsActive = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - _context.MediaRetentionPolicies.Add(policy); - await _context.SaveChangesAsync(); - - _logger.LogInformation("Created media retention policy {PolicyId}: {PolicyName}", policy.Id, policy.Name); - - return CreatedAtAction(nameof(GetPolicy), new { id = policy.Id }, new MediaRetentionPolicyDto - { - Id = policy.Id, - Name = policy.Name, - Description = policy.Description, - PositiveBalanceRetentionDays = policy.PositiveBalanceRetentionDays, - ZeroBalanceRetentionDays = policy.ZeroBalanceRetentionDays, - NegativeBalanceRetentionDays = policy.NegativeBalanceRetentionDays, - SoftDeleteGracePeriodDays = policy.SoftDeleteGracePeriodDays, - RespectRecentAccess = policy.RespectRecentAccess, - RecentAccessWindowDays = policy.RecentAccessWindowDays, - IsDefault = policy.IsDefault, - MaxStorageSizeBytes = policy.MaxStorageSizeBytes, - MaxFileCount = policy.MaxFileCount, - IsActive = policy.IsActive, - CreatedAt = policy.CreatedAt, - UpdatedAt = policy.UpdatedAt, - VirtualKeyGroupCount = 0 - }); + if (request.IsDefault) + { + // Ensure only one default policy exists + var existingDefault = await _context.MediaRetentionPolicies + .FirstOrDefaultAsync(p => p.IsDefault); + if (existingDefault != null) + { + existingDefault.IsDefault = false; + } + } + + var policy = new MediaRetentionPolicy + { + Name = request.Name, + Description = request.Description, + PositiveBalanceRetentionDays = request.PositiveBalanceRetentionDays, + ZeroBalanceRetentionDays = request.ZeroBalanceRetentionDays, + NegativeBalanceRetentionDays = request.NegativeBalanceRetentionDays, + SoftDeleteGracePeriodDays = request.SoftDeleteGracePeriodDays, + RespectRecentAccess = request.RespectRecentAccess, + RecentAccessWindowDays = request.RecentAccessWindowDays, + IsDefault = request.IsDefault, + MaxStorageSizeBytes = request.MaxStorageSizeBytes, + MaxFileCount = request.MaxFileCount, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _context.MediaRetentionPolicies.Add(policy); + await _context.SaveChangesAsync(); + + LogAdminAudit("Created", "MediaRetentionPolicy", policy.Id, $"Name: {LoggingSanitizer.S(policy.Name)}"); + + return new MediaRetentionPolicyDto + { + Id = policy.Id, + Name = policy.Name, + Description = policy.Description, + PositiveBalanceRetentionDays = policy.PositiveBalanceRetentionDays, + ZeroBalanceRetentionDays = policy.ZeroBalanceRetentionDays, + NegativeBalanceRetentionDays = policy.NegativeBalanceRetentionDays, + SoftDeleteGracePeriodDays = policy.SoftDeleteGracePeriodDays, + RespectRecentAccess = policy.RespectRecentAccess, + RecentAccessWindowDays = policy.RecentAccessWindowDays, + IsDefault = policy.IsDefault, + MaxStorageSizeBytes = policy.MaxStorageSizeBytes, + MaxFileCount = policy.MaxFileCount, + IsActive = policy.IsActive, + CreatedAt = policy.CreatedAt, + UpdatedAt = policy.UpdatedAt, + VirtualKeyGroupCount = 0 + }; + }, + dto => CreatedAtAction(nameof(GetPolicy), new { id = dto.Id }, dto), + nameof(CreatePolicy)); } /// @@ -194,66 +197,65 @@ public async Task CreatePolicy([FromBody] CreateMediaRetentionPol [ProducesResponseType(typeof(MediaRetentionPolicyDto), 200)] [ProducesResponseType(404)] [ProducesResponseType(400)] - public async Task UpdatePolicy(int id, [FromBody] UpdateMediaRetentionPolicyRequest request) + public Task UpdatePolicy(int id, [FromBody] UpdateMediaRetentionPolicyRequest request) { - var policy = await _context.MediaRetentionPolicies - .Include(p => p.VirtualKeyGroups) - .FirstOrDefaultAsync(p => p.Id == id); - - if (policy == null) - { - return NotFound(new { message = $"Policy with ID {id} not found" }); - } - - if (request.IsDefault == true && !policy.IsDefault) - { - // Ensure only one default policy exists - var existingDefault = await _context.MediaRetentionPolicies - .FirstOrDefaultAsync(p => p.IsDefault && p.Id != id); - if (existingDefault != null) + return ExecuteWithNotFoundAsync( + () => _context.MediaRetentionPolicies + .Include(p => p.VirtualKeyGroups) + .FirstOrDefaultAsync(p => p.Id == id), + async policy => { - existingDefault.IsDefault = false; - } - } - - // Update fields - policy.Name = request.Name ?? policy.Name; - policy.Description = request.Description ?? policy.Description; - policy.PositiveBalanceRetentionDays = request.PositiveBalanceRetentionDays ?? policy.PositiveBalanceRetentionDays; - policy.ZeroBalanceRetentionDays = request.ZeroBalanceRetentionDays ?? policy.ZeroBalanceRetentionDays; - policy.NegativeBalanceRetentionDays = request.NegativeBalanceRetentionDays ?? policy.NegativeBalanceRetentionDays; - policy.SoftDeleteGracePeriodDays = request.SoftDeleteGracePeriodDays ?? policy.SoftDeleteGracePeriodDays; - policy.RespectRecentAccess = request.RespectRecentAccess ?? policy.RespectRecentAccess; - policy.RecentAccessWindowDays = request.RecentAccessWindowDays ?? policy.RecentAccessWindowDays; - policy.IsDefault = request.IsDefault ?? policy.IsDefault; - policy.MaxStorageSizeBytes = request.MaxStorageSizeBytes ?? policy.MaxStorageSizeBytes; - policy.MaxFileCount = request.MaxFileCount ?? policy.MaxFileCount; - policy.IsActive = request.IsActive ?? policy.IsActive; - policy.UpdatedAt = DateTime.UtcNow; - - await _context.SaveChangesAsync(); - - _logger.LogInformation("Updated media retention policy {PolicyId}: {PolicyName}", policy.Id, policy.Name); - - return Ok(new MediaRetentionPolicyDto - { - Id = policy.Id, - Name = policy.Name, - Description = policy.Description, - PositiveBalanceRetentionDays = policy.PositiveBalanceRetentionDays, - ZeroBalanceRetentionDays = policy.ZeroBalanceRetentionDays, - NegativeBalanceRetentionDays = policy.NegativeBalanceRetentionDays, - SoftDeleteGracePeriodDays = policy.SoftDeleteGracePeriodDays, - RespectRecentAccess = policy.RespectRecentAccess, - RecentAccessWindowDays = policy.RecentAccessWindowDays, - IsDefault = policy.IsDefault, - MaxStorageSizeBytes = policy.MaxStorageSizeBytes, - MaxFileCount = policy.MaxFileCount, - IsActive = policy.IsActive, - CreatedAt = policy.CreatedAt, - UpdatedAt = policy.UpdatedAt, - VirtualKeyGroupCount = policy.VirtualKeyGroups.Count - }); + if (request.IsDefault == true && !policy.IsDefault) + { + // Ensure only one default policy exists + var existingDefault = await _context.MediaRetentionPolicies + .FirstOrDefaultAsync(p => p.IsDefault && p.Id != id); + if (existingDefault != null) + { + existingDefault.IsDefault = false; + } + } + + // Update fields + policy.Name = request.Name ?? policy.Name; + policy.Description = request.Description ?? policy.Description; + policy.PositiveBalanceRetentionDays = request.PositiveBalanceRetentionDays ?? policy.PositiveBalanceRetentionDays; + policy.ZeroBalanceRetentionDays = request.ZeroBalanceRetentionDays ?? policy.ZeroBalanceRetentionDays; + policy.NegativeBalanceRetentionDays = request.NegativeBalanceRetentionDays ?? policy.NegativeBalanceRetentionDays; + policy.SoftDeleteGracePeriodDays = request.SoftDeleteGracePeriodDays ?? policy.SoftDeleteGracePeriodDays; + policy.RespectRecentAccess = request.RespectRecentAccess ?? policy.RespectRecentAccess; + policy.RecentAccessWindowDays = request.RecentAccessWindowDays ?? policy.RecentAccessWindowDays; + policy.IsDefault = request.IsDefault ?? policy.IsDefault; + policy.MaxStorageSizeBytes = request.MaxStorageSizeBytes ?? policy.MaxStorageSizeBytes; + policy.MaxFileCount = request.MaxFileCount ?? policy.MaxFileCount; + policy.IsActive = request.IsActive ?? policy.IsActive; + policy.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + LogAdminAudit("Updated", "MediaRetentionPolicy", policy.Id, $"Name: {LoggingSanitizer.S(policy.Name)}"); + + return Ok(new MediaRetentionPolicyDto + { + Id = policy.Id, + Name = policy.Name, + Description = policy.Description, + PositiveBalanceRetentionDays = policy.PositiveBalanceRetentionDays, + ZeroBalanceRetentionDays = policy.ZeroBalanceRetentionDays, + NegativeBalanceRetentionDays = policy.NegativeBalanceRetentionDays, + SoftDeleteGracePeriodDays = policy.SoftDeleteGracePeriodDays, + RespectRecentAccess = policy.RespectRecentAccess, + RecentAccessWindowDays = policy.RecentAccessWindowDays, + IsDefault = policy.IsDefault, + MaxStorageSizeBytes = policy.MaxStorageSizeBytes, + MaxFileCount = policy.MaxFileCount, + IsActive = policy.IsActive, + CreatedAt = policy.CreatedAt, + UpdatedAt = policy.UpdatedAt, + VirtualKeyGroupCount = policy.VirtualKeyGroups.Count + }); + }, + "Retention policy", id, nameof(UpdatePolicy)); } /// @@ -265,33 +267,33 @@ public async Task UpdatePolicy(int id, [FromBody] UpdateMediaRete [ProducesResponseType(204)] [ProducesResponseType(404)] [ProducesResponseType(400)] - public async Task DeletePolicy(int id) + public Task DeletePolicy(int id) { - var policy = await _context.MediaRetentionPolicies - .Include(p => p.VirtualKeyGroups) - .FirstOrDefaultAsync(p => p.Id == id); - - if (policy == null) - { - return NotFound(new { message = $"Policy with ID {id} not found" }); - } - - if (policy.IsDefault) - { - return BadRequest(new { message = "Cannot delete the default retention policy" }); - } + return ExecuteWithNotFoundAsync( + () => _context.MediaRetentionPolicies + .Include(p => p.VirtualKeyGroups) + .FirstOrDefaultAsync(p => p.Id == id), + async policy => + { + if (policy.IsDefault) + { + return this.BadRequestError("Cannot delete the default retention policy"); + } - if (policy.VirtualKeyGroups.Any()) - { - return BadRequest(new { message = $"Cannot delete policy - it is assigned to {policy.VirtualKeyGroups.Count} virtual key group(s)" }); - } + if (policy.VirtualKeyGroups.Any()) + { + return this.BadRequestError( + $"Cannot delete policy - it is assigned to {policy.VirtualKeyGroups.Count} virtual key group(s)"); + } - _context.MediaRetentionPolicies.Remove(policy); - await _context.SaveChangesAsync(); + _context.MediaRetentionPolicies.Remove(policy); + await _context.SaveChangesAsync(); - _logger.LogInformation("Deleted media retention policy {PolicyId}: {PolicyName}", policy.Id, policy.Name); + LogAdminAudit("Deleted", "MediaRetentionPolicy", policy.Id, $"Name: {LoggingSanitizer.S(policy.Name)}"); - return NoContent(); + return NoContent(); + }, + "Retention policy", id, nameof(DeletePolicy)); } /// @@ -303,26 +305,29 @@ public async Task DeletePolicy(int id) [HttpPost("assign/{groupId}/{policyId}")] [ProducesResponseType(200)] [ProducesResponseType(404)] - public async Task AssignPolicyToGroup(int groupId, int policyId) + public Task AssignPolicyToGroup(int groupId, int policyId) { - var group = await _context.VirtualKeyGroups.FindAsync(groupId); - if (group == null) + return ExecuteAsync(async () => { - return NotFound(new { message = $"Virtual key group with ID {groupId} not found" }); - } + var group = await _context.VirtualKeyGroups.FindAsync(groupId); + if (group == null) + { + return this.NotFoundEntity("Virtual key group", groupId); + } - var policy = await _context.MediaRetentionPolicies.FindAsync(policyId); - if (policy == null) - { - return NotFound(new { message = $"Retention policy with ID {policyId} not found" }); - } + var policy = await _context.MediaRetentionPolicies.FindAsync(policyId); + if (policy == null) + { + return this.NotFoundEntity("Retention policy", policyId); + } - group.MediaRetentionPolicyId = policyId; - await _context.SaveChangesAsync(); + group.MediaRetentionPolicyId = policyId; + await _context.SaveChangesAsync(); - _logger.LogInformation("Assigned retention policy {PolicyId} to virtual key group {GroupId}", policyId, groupId); + LogAdminAudit("AssignedPolicy", "MediaRetentionPolicy", policyId, $"GroupId: {groupId}"); - return Ok(new { message = $"Successfully assigned policy '{policy.Name}' to group {groupId}" }); + return Ok(new { message = $"Successfully assigned policy '{policy.Name}' to group {groupId}" }); + }, nameof(AssignPolicyToGroup), new { groupId, policyId }); } /// @@ -334,35 +339,35 @@ public async Task AssignPolicyToGroup(int groupId, int policyId) [HttpPost("policies/{id}/set-default")] [ProducesResponseType(200)] [ProducesResponseType(404)] - public async Task SetDefaultPolicy(int id) + public Task SetDefaultPolicy(int id) { - var policy = await _context.MediaRetentionPolicies.FindAsync(id); - if (policy == null) - { - return NotFound(new { message = $"Policy with ID {id} not found" }); - } - - if (!policy.IsActive) - { - return BadRequest(new { message = "Cannot set an inactive policy as default" }); - } - - // Clear existing default - var currentDefault = await _context.MediaRetentionPolicies - .FirstOrDefaultAsync(p => p.IsDefault && p.Id != id); - if (currentDefault != null) - { - currentDefault.IsDefault = false; - } - - // Set new default - policy.IsDefault = true; - policy.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); - - _logger.LogInformation("Set retention policy {PolicyId} '{PolicyName}' as default", policy.Id, policy.Name); - - return Ok(new { message = $"'{policy.Name}' is now the default retention policy" }); + return ExecuteWithNotFoundAsync( + () => _context.MediaRetentionPolicies.FindAsync(id).AsTask(), + async policy => + { + if (!policy.IsActive) + { + return this.BadRequestError("Cannot set an inactive policy as default"); + } + + // Clear existing default + var currentDefault = await _context.MediaRetentionPolicies + .FirstOrDefaultAsync(p => p.IsDefault && p.Id != id); + if (currentDefault != null) + { + currentDefault.IsDefault = false; + } + + // Set new default + policy.IsDefault = true; + policy.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + LogAdminAudit("SetDefault", "MediaRetentionPolicy", policy.Id, $"Name: {LoggingSanitizer.S(policy.Name)}"); + + return Ok(new { message = $"'{policy.Name}' is now the default retention policy" }); + }, + "Retention policy", id, nameof(SetDefaultPolicy)); } /// @@ -376,8 +381,7 @@ public async Task SetDefaultPolicy(int id) [ProducesResponseType(404)] public Task TriggerCleanup(int groupId, [FromQuery] bool dryRun = true) { - // This would trigger the media cleanup process - // For now, return a placeholder response + // Placeholder โ€” manual cleanup not yet implemented return Task.FromResult(Ok(new CleanupResultDto { VirtualKeyGroupId = groupId, @@ -391,293 +395,4 @@ public Task TriggerCleanup(int groupId, [FromQuery] bool dryRun = } } - #region DTOs - - /// - /// Data transfer object for media retention policy information. - /// - public class MediaRetentionPolicyDto - { - /// - /// Gets or sets the unique identifier of the retention policy. - /// - public int Id { get; set; } - - /// - /// Gets or sets the name of the retention policy. - /// - public string Name { get; set; } = string.Empty; - - /// - /// Gets or sets the description of the retention policy. - /// - public string? Description { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is positive. - /// - public int PositiveBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is zero. - /// - public int ZeroBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is negative. - /// - public int NegativeBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the grace period in days before permanently deleting soft-deleted media. - /// - public int SoftDeleteGracePeriodDays { get; set; } - - /// - /// Gets or sets a value indicating whether to respect recent access when determining retention. - /// - public bool RespectRecentAccess { get; set; } - - /// - /// Gets or sets the window in days for considering recent access. - /// - public int RecentAccessWindowDays { get; set; } - - /// - /// Gets or sets a value indicating whether this is the default policy. - /// - public bool IsDefault { get; set; } - - /// - /// Gets or sets the maximum storage size in bytes allowed for this policy. - /// - public long? MaxStorageSizeBytes { get; set; } - - /// - /// Gets or sets the maximum number of files allowed for this policy. - /// - public int? MaxFileCount { get; set; } - - /// - /// Gets or sets a value indicating whether this policy is active. - /// - public bool IsActive { get; set; } - - /// - /// Gets or sets the date and time when the policy was created. - /// - public DateTime CreatedAt { get; set; } - - /// - /// Gets or sets the date and time when the policy was last updated. - /// - public DateTime UpdatedAt { get; set; } - - /// - /// Gets or sets the count of virtual key groups using this policy. - /// - public int VirtualKeyGroupCount { get; set; } - } - - /// - /// Extended DTO for media retention policy with additional details. - /// - public class MediaRetentionPolicyDetailDto : MediaRetentionPolicyDto - { - /// - /// Gets or sets the list of virtual key groups associated with this policy. - /// - public List VirtualKeyGroups { get; set; } = new(); - } - - /// - /// Summary information for a virtual key group. - /// - public class VirtualKeyGroupSummaryDto - { - /// - /// Gets or sets the virtual key group identifier. - /// - public int Id { get; set; } - - /// - /// Gets or sets the current balance of the virtual key group. - /// - public decimal Balance { get; set; } - - /// - /// Gets or sets the count of virtual keys in the group. - /// - public int VirtualKeyCount { get; set; } - } - - /// - /// Request model for creating a new media retention policy. - /// - public class CreateMediaRetentionPolicyRequest - { - /// - /// Gets or sets the name of the retention policy. - /// - public string Name { get; set; } = string.Empty; - - /// - /// Gets or sets the description of the retention policy. - /// - public string? Description { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is positive. - /// - public int PositiveBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is zero. - /// - public int ZeroBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is negative. - /// - public int NegativeBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the grace period in days before permanently deleting soft-deleted media. - /// - public int SoftDeleteGracePeriodDays { get; set; } = 7; - - /// - /// Gets or sets a value indicating whether to respect recent access when determining retention. - /// - public bool RespectRecentAccess { get; set; } = true; - - /// - /// Gets or sets the window in days for considering recent access. - /// - public int RecentAccessWindowDays { get; set; } = 7; - - /// - /// Gets or sets a value indicating whether this is the default policy. - /// - public bool IsDefault { get; set; } - - /// - /// Gets or sets the maximum storage size in bytes allowed for this policy. - /// - public long? MaxStorageSizeBytes { get; set; } - - /// - /// Gets or sets the maximum number of files allowed for this policy. - /// - public int? MaxFileCount { get; set; } - } - - /// - /// Request model for updating an existing media retention policy. - /// - public class UpdateMediaRetentionPolicyRequest - { - /// - /// Gets or sets the name of the retention policy. - /// - public string? Name { get; set; } - - /// - /// Gets or sets the description of the retention policy. - /// - public string? Description { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is positive. - /// - public int? PositiveBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is zero. - /// - public int? ZeroBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the retention period in days for media when balance is negative. - /// - public int? NegativeBalanceRetentionDays { get; set; } - - /// - /// Gets or sets the grace period in days before permanently deleting soft-deleted media. - /// - public int? SoftDeleteGracePeriodDays { get; set; } - - /// - /// Gets or sets a value indicating whether to respect recent access when determining retention. - /// - public bool? RespectRecentAccess { get; set; } - - /// - /// Gets or sets the window in days for considering recent access. - /// - public int? RecentAccessWindowDays { get; set; } - - /// - /// Gets or sets a value indicating whether this is the default policy. - /// - public bool? IsDefault { get; set; } - - /// - /// Gets or sets the maximum storage size in bytes allowed for this policy. - /// - public long? MaxStorageSizeBytes { get; set; } - - /// - /// Gets or sets the maximum number of files allowed for this policy. - /// - public int? MaxFileCount { get; set; } - - /// - /// Gets or sets a value indicating whether this policy is active. - /// - public bool? IsActive { get; set; } - } - - /// - /// Represents the result of a media cleanup operation. - /// - public class CleanupResultDto - { - /// - /// Gets or sets the ID of the virtual key group that was cleaned up. - /// - public int VirtualKeyGroupId { get; set; } - - /// - /// Gets or sets a value indicating whether this was a dry run (no actual deletions). - /// - public bool DryRun { get; set; } - - /// - /// Gets or sets the total number of media records evaluated during cleanup. - /// - public int MediaRecordsEvaluated { get; set; } - - /// - /// Gets or sets the number of media records marked for deletion. - /// - public int MediaRecordsMarkedForDeletion { get; set; } - - /// - /// Gets or sets the number of media records actually deleted. - /// - public int MediaRecordsDeleted { get; set; } - - /// - /// Gets or sets the total amount of storage space freed in bytes. - /// - public long StorageBytesFreed { get; set; } - - /// - /// Gets or sets an informational message about the cleanup operation. - /// - public string Message { get; set; } = string.Empty; - } - - #endregion } \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Controllers/MetricsController.cs b/Services/ConduitLLM.Admin/Controllers/MetricsController.cs index 440d8fb78..a73a4aa61 100644 --- a/Services/ConduitLLM.Admin/Controllers/MetricsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/MetricsController.cs @@ -16,10 +16,9 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("metrics")] [Authorize(Policy = "MasterKeyPolicy")] - public class MetricsController : ControllerBase + public class MetricsController : AdminControllerBase { private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -29,9 +28,9 @@ public class MetricsController : ControllerBase public MetricsController( IDbContextFactory dbContextFactory, ILogger logger) + : base(logger) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -40,72 +39,68 @@ public MetricsController( /// Cancellation token. /// Connection pool metrics. [HttpGet("database/pool")] - public async Task GetDatabasePoolMetrics(CancellationToken cancellationToken = default) + public Task GetDatabasePoolMetrics(CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var connection = dbContext.Database.GetDbConnection() as NpgsqlConnection; - - if (connection == null) + return ExecuteAsync( + async () => { - return Ok(new - { - provider = "non-postgresql", - message = "Connection pool metrics only available for PostgreSQL" - }); - } - - // Get connection string to extract pool settings - var connectionString = connection.ConnectionString; - var builder = new NpgsqlConnectionStringBuilder(connectionString); - - // Measure connection acquisition time - var stopwatch = Stopwatch.StartNew(); - await connection.OpenAsync(cancellationToken); - stopwatch.Stop(); - await connection.CloseAsync(); + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var connection = dbContext.Database.GetDbConnection() as NpgsqlConnection; - // Note: Npgsql doesn't expose pool statistics directly in current versions - // We can only infer pool health from connection acquisition time - // For detailed monitoring, use PostgreSQL's pg_stat_activity or external monitoring tools - - var metrics = new - { - timestamp = DateTime.UtcNow, - provider = "postgresql", - connectionString = new - { - host = builder.Host, - port = builder.Port, - database = builder.Database, - applicationName = builder.ApplicationName ?? "Conduit Gateway API" - }, - poolConfiguration = new + if (connection == null) { - minPoolSize = builder.MinPoolSize, - maxPoolSize = builder.MaxPoolSize, - connectionLifetime = builder.ConnectionLifetime, - connectionIdleLifetime = builder.ConnectionIdleLifetime, - pooling = builder.Pooling - }, - currentMetrics = new - { - connectionAcquisitionTimeMs = stopwatch.ElapsedMilliseconds, - healthStatus = GetHealthStatus(stopwatch.ElapsedMilliseconds), - // Additional metrics can be obtained from pg_stat_activity if needed - // but we avoid that here to prevent performance impact - note = "For detailed pool statistics, query pg_stat_activity directly or use monitoring tools" + return (object)new + { + provider = "non-postgresql", + message = "Connection pool metrics only available for PostgreSQL" + }; } - }; - return Ok(metrics); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve database pool metrics"); - return StatusCode(500, new { error = "Failed to retrieve metrics", message = ex.Message }); - } + // Get connection string to extract pool settings + var connectionString = connection.ConnectionString; + var builder = new NpgsqlConnectionStringBuilder(connectionString); + + // Measure connection acquisition time + var stopwatch = Stopwatch.StartNew(); + await connection.OpenAsync(cancellationToken); + stopwatch.Stop(); + await connection.CloseAsync(); + + // Note: Npgsql doesn't expose pool statistics directly in current versions + // We can only infer pool health from connection acquisition time + // For detailed monitoring, use PostgreSQL's pg_stat_activity or external monitoring tools + + return (object)new + { + timestamp = DateTime.UtcNow, + provider = "postgresql", + connectionString = new + { + host = builder.Host, + port = builder.Port, + database = builder.Database, + applicationName = builder.ApplicationName ?? "Conduit Gateway API" + }, + poolConfiguration = new + { + minPoolSize = builder.MinPoolSize, + maxPoolSize = builder.MaxPoolSize, + connectionLifetime = builder.ConnectionLifetime, + connectionIdleLifetime = builder.ConnectionIdleLifetime, + pooling = builder.Pooling + }, + currentMetrics = new + { + connectionAcquisitionTimeMs = stopwatch.ElapsedMilliseconds, + healthStatus = GetHealthStatus(stopwatch.ElapsedMilliseconds), + // Additional metrics can be obtained from pg_stat_activity if needed + // but we avoid that here to prevent performance impact + note = "For detailed pool statistics, query pg_stat_activity directly or use monitoring tools" + } + }; + }, + Ok, + "GetDatabasePoolMetrics"); } /// @@ -114,41 +109,37 @@ public async Task GetDatabasePoolMetrics(CancellationToken cancel /// Cancellation token. /// Comprehensive application metrics. [HttpGet] - public async Task GetAllMetrics(CancellationToken cancellationToken = default) + public Task GetAllMetrics(CancellationToken cancellationToken = default) { - try - { - // Get database pool metrics - var poolMetricsResult = await GetDatabasePoolMetrics(cancellationToken); - var poolMetrics = (poolMetricsResult as OkObjectResult)?.Value; - - var allMetrics = new + return ExecuteAsync( + async () => { - timestamp = DateTime.UtcNow, - application = new - { - name = "Conduit Gateway API", - version = typeof(MetricsController).Assembly.GetName().Version?.ToString() ?? "unknown", - environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" - }, - database = poolMetrics, - system = new - { - cpuCount = Environment.ProcessorCount, - workingSetMb = Environment.WorkingSet / 1024 / 1024, - gcMemoryMb = GC.GetTotalMemory(false) / 1024 / 1024, - threadCount = Process.GetCurrentProcess().Threads.Count, - uptime = DateTime.UtcNow - Process.GetCurrentProcess().StartTime.ToUniversalTime() - } - }; + // Get database pool metrics + var poolMetricsResult = await GetDatabasePoolMetrics(cancellationToken); + var poolMetrics = (poolMetricsResult as OkObjectResult)?.Value; - return Ok(allMetrics); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve application metrics"); - return StatusCode(500, new { error = "Failed to retrieve metrics", message = ex.Message }); - } + return new + { + timestamp = DateTime.UtcNow, + application = new + { + name = "Conduit Gateway API", + version = typeof(MetricsController).Assembly.GetName().Version?.ToString() ?? "unknown", + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" + }, + database = poolMetrics, + system = new + { + cpuCount = Environment.ProcessorCount, + workingSetMb = Environment.WorkingSet / 1024 / 1024, + gcMemoryMb = GC.GetTotalMemory(false) / 1024 / 1024, + threadCount = Process.GetCurrentProcess().Threads.Count, + uptime = DateTime.UtcNow - Process.GetCurrentProcess().StartTime.ToUniversalTime() + } + }; + }, + Ok, + "GetAllMetrics"); } private static string GetHealthStatus(long acquisitionTimeMs) diff --git a/Services/ConduitLLM.Admin/Controllers/ModelAuthorController.cs b/Services/ConduitLLM.Admin/Controllers/ModelAuthorController.cs index c95edb976..2f7ff5be2 100644 --- a/Services/ConduitLLM.Admin/Controllers/ModelAuthorController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ModelAuthorController.cs @@ -1,6 +1,9 @@ +using ConduitLLM.Admin.Extensions; using ConduitLLM.Admin.Models.ModelAuthors; using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Repositories; +using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,10 +16,9 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class ModelAuthorController : ControllerBase + public class ModelAuthorController : AdminControllerBase { private readonly IModelAuthorRepository _repository; - private readonly ILogger _logger; /// /// Initializes a new instance of the ModelAuthorController @@ -24,9 +26,9 @@ public class ModelAuthorController : ControllerBase public ModelAuthorController( IModelAuthorRepository repository, ILogger logger) + : base(logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -36,19 +38,17 @@ public ModelAuthorController( [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAll() + public Task GetAll() { - try - { - var authors = await _repository.GetAllAsync(); - var dtos = authors.Select(a => MapToDto(a)); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all model authors"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving model authors"); - } + return ExecuteAsync( + async () => + { + var authors = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _repository.GetPaginatedAsync); + return authors.Select(a => a.ToDto()); + }, + Ok, + "GetAll"); } /// @@ -60,23 +60,14 @@ public async Task GetAll() [ProducesResponseType(typeof(ModelAuthorDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetById(int id) + public Task GetById(int id) { - try - { - var author = await _repository.GetByIdAsync(id); - if (author == null) - { - return NotFound($"Model author with ID {id} not found"); - } - - return Ok(MapToDto(author)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model author with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving the model author"); - } + return ExecuteWithNotFoundAsync( + () => _repository.GetByIdAsync(id), + author => Ok(author.ToDto()), + "Model author", + id, + "GetById"); } /// @@ -88,31 +79,25 @@ public async Task GetById(int id) [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetSeriesByAuthor(int id) + public Task GetSeriesByAuthor(int id) { - try - { - var series = await _repository.GetSeriesByAuthorAsync(id); - if (series == null) - { - return NotFound($"Model author with ID {id} not found"); - } - - var dtos = series.Select(s => new SimpleModelSeriesDto + return ExecuteWithNotFoundAsync( + () => _repository.GetSeriesByAuthorAsync(id), + series => { - Id = s.Id, - Name = s.Name, - Description = s.Description, - TokenizerType = s.TokenizerType - }); - - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting series for author {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving series"); - } + var dtos = series.Select(s => new SimpleModelSeriesDto + { + Id = s.Id, + Name = s.Name, + Description = s.Description, + TokenizerType = s.TokenizerType + }); + + return Ok(dtos); + }, + "Model author", + id, + "GetSeriesByAuthor"); } /// @@ -125,41 +110,35 @@ public async Task GetSeriesByAuthor(int id) [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task Create([FromBody] CreateModelAuthorDto dto) + public Task Create([FromBody] CreateModelAuthorDto dto) { - try - { - if (!ModelState.IsValid) + return ExecuteAsync( + async () => { - return BadRequest(ModelState); - } + // Check if author with same name already exists + var existing = await _repository.GetByNameAsync(dto.Name); + if (existing != null) + { + throw new InvalidOperationException($"A model author with name '{dto.Name}' already exists"); + } - // Check if author with same name already exists - var existing = await _repository.GetByNameAsync(dto.Name); - if (existing != null) - { - return Conflict($"A model author with name '{dto.Name}' already exists"); - } + var author = new ModelAuthor + { + Name = dto.Name, + Description = dto.Description, + WebsiteUrl = dto.WebsiteUrl + }; - var author = new ModelAuthor - { - Name = dto.Name, - Description = dto.Description, - WebsiteUrl = dto.WebsiteUrl - }; + await _repository.CreateAsync(author); + LogAdminAudit("Created", "ModelAuthor", author.Id, $"Name: {LoggingSanitizer.S(author.Name)}"); - await _repository.CreateAsync(author); - - return CreatedAtAction( + return author; + }, + author => CreatedAtAction( nameof(GetById), new { id = author.Id }, - MapToDto(author)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating model author"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating the model author"); - } + author.ToDto()), + "Create"); } /// @@ -174,51 +153,44 @@ public async Task Create([FromBody] CreateModelAuthorDto dto) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task Update(int id, [FromBody] UpdateModelAuthorDto dto) + public Task Update(int id, [FromBody] UpdateModelAuthorDto dto) { - try + if (id != dto.Id) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - if (id != dto.Id) - { - return BadRequest("ID mismatch"); - } - - var author = await _repository.GetByIdAsync(id); - if (author == null) - { - return NotFound($"Model author with ID {id} not found"); - } + return Task.FromResult(BadRequest("ID mismatch")); + } - // Check for name conflicts if name is being changed - if (!string.IsNullOrEmpty(dto.Name) && dto.Name != author.Name) + return ExecuteAsync( + async () => { - var existing = await _repository.GetByNameAsync(dto.Name); - if (existing != null && existing.Id != id) + var author = await _repository.GetByIdAsync(id); + if (author == null) { - return Conflict($"A model author with name '{dto.Name}' already exists"); + throw new KeyNotFoundException($"Model author with ID {id} not found"); } - author.Name = dto.Name; - } - - if (dto.Description != null) - author.Description = dto.Description; - if (dto.WebsiteUrl != null) - author.WebsiteUrl = dto.WebsiteUrl; - await _repository.UpdateAsync(author); + // Check for name conflicts if name is being changed + if (!string.IsNullOrEmpty(dto.Name) && dto.Name != author.Name) + { + var existing = await _repository.GetByNameAsync(dto.Name); + if (existing != null && existing.Id != id) + { + throw new InvalidOperationException($"A model author with name '{dto.Name}' already exists"); + } + author.Name = dto.Name; + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating model author with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while updating the model author"); - } + if (dto.Description != null) + author.Description = dto.Description; + if (dto.WebsiteUrl != null) + author.WebsiteUrl = dto.WebsiteUrl; + + await _repository.UpdateAsync(author); + LogAdminAudit("Updated", "ModelAuthor", id, $"Name: {LoggingSanitizer.S(author.Name)}"); + }, + NoContent(), + "Update", + new { Id = id }); } /// @@ -231,43 +203,31 @@ public async Task Update(int id, [FromBody] UpdateModelAuthorDto [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task Delete(int id) + public Task Delete(int id) { - try - { - var author = await _repository.GetByIdAsync(id); - if (author == null) - { - return NotFound($"Model author with ID {id} not found"); - } - - // Check if author has series - var series = await _repository.GetSeriesByAuthorAsync(id); - if (series != null && series.Any()) + return ExecuteAsync( + async () => { - return Conflict($"Cannot delete model author with {series.Count()} associated series. Delete the series first."); - } + var author = await _repository.GetByIdAsync(id); + if (author == null) + { + throw new KeyNotFoundException($"Model author with ID {id} not found"); + } - await _repository.DeleteAsync(id); + // Check if author has series + var series = await _repository.GetSeriesByAuthorAsync(id); + if (series != null && series.Any()) + { + throw new InvalidOperationException($"Cannot delete model author with {series.Count()} associated series. Delete the series first."); + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting model author with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while deleting the model author"); - } + await _repository.DeleteAsync(id); + LogAdminAudit("Deleted", "ModelAuthor", id, $"Name: {LoggingSanitizer.S(author.Name)}"); + }, + NoContent(), + "Delete", + new { Id = id }); } - private static ModelAuthorDto MapToDto(ModelAuthor author) - { - return new ModelAuthorDto - { - Id = author.Id, - Name = author.Name, - Description = author.Description, - WebsiteUrl = author.WebsiteUrl - }; - } } } diff --git a/Services/ConduitLLM.Admin/Controllers/ModelController.Identifiers.cs b/Services/ConduitLLM.Admin/Controllers/ModelController.Identifiers.cs new file mode 100644 index 000000000..4285ffcb8 --- /dev/null +++ b/Services/ConduitLLM.Admin/Controllers/ModelController.Identifiers.cs @@ -0,0 +1,283 @@ +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Admin.Models.Models; +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Core.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace ConduitLLM.Admin.Controllers +{ + public partial class ModelController + { + /// + /// Gets model identifiers for a specific model + /// + /// The model ID + /// List of model identifiers showing which providers offer this model + [HttpGet("{id}/identifiers")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task GetModelIdentifiers(int id) + { + return ExecuteWithNotFoundAsync( + () => _modelRepository.GetByIdWithDetailsAsync(id), + model => + { + var identifiers = model.Identifiers.Select(i => new + { + id = i.Id, + identifier = i.Identifier, + provider = (int?)i.Provider, + isPrimary = i.IsPrimary, + maxInputTokens = i.MaxInputTokens, + maxOutputTokens = i.MaxOutputTokens, + speedScore = i.SpeedScore, + qualityScore = i.QualityScore, + providerVariation = i.ProviderVariation, + modelCostId = i.ModelCostId + }); + + return Ok(identifiers); + }, + "Model", id, "GetModelIdentifiers"); + } + + /// + /// Gets model associations with available providers + /// Returns only associations where matching providers are configured + /// + /// The model ID + /// List of associations with their available providers + [HttpGet("{id}/available-providers")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task GetAvailableProviders(int id) + { + return ExecuteWithNotFoundAsync( + () => _modelRepository.GetByIdWithDetailsAsync(id), + async model => + { + var providers = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _providerRepository.GetPaginatedAsync); + var enabledProviders = providers.Where(p => p.IsEnabled).ToList(); + + var result = new List(); + + foreach (var association in model.Identifiers) + { + // Skip associations without a provider type - they're not properly configured + if (association.Provider == null) + { + Logger.LogWarning( + "ModelIdentifier {AssociationId} for model {ModelId} has null Provider field - skipping", + association.Id, id); + continue; + } + + // Find matching providers for this association + var matchingProviders = enabledProviders.Where(p => + p.ProviderType == association.Provider + ).ToList(); + + if (matchingProviders.Any()) + { + result.Add(new + { + associationId = association.Id, + identifier = association.Identifier, + provider = (int?)association.Provider, + providerVariation = association.ProviderVariation, + maxInputTokens = association.MaxInputTokens, + maxOutputTokens = association.MaxOutputTokens, + speedScore = association.SpeedScore, + qualityScore = association.QualityScore, + isPrimary = association.IsPrimary, + availableProviders = matchingProviders.Select(p => new + { + providerId = p.Id, + providerName = p.ProviderName, + providerType = p.ProviderType.ToString() + }) + }); + } + } + + return (IActionResult)Ok(result); + }, + "Model", id, "GetAvailableProviders"); + } + + /// + /// Creates a new model identifier for a specific model + /// + /// The model ID + /// The identifier data + /// The created identifier + [HttpPost("{id}/identifiers")] + [ProducesResponseType(typeof(object), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public Task CreateModelIdentifier(int id, [FromBody] CreateModelIdentifierDto dto) + { + return ExecuteAsync( + async () => + { + var model = await _modelRepository.GetByIdWithDetailsAsync(id); + if (model == null) + { + return (IActionResult)NotFound($"Model with ID {id} not found"); + } + + // Parse provider if provided as integer + ProviderType? providerType = dto.Provider.HasValue ? (ProviderType)dto.Provider.Value : null; + + // Check if identifier already exists for this provider + var existing = model.Identifiers.FirstOrDefault(i => + i.Identifier == dto.Identifier && + i.Provider == providerType); + + if (existing != null) + { + return Conflict($"Identifier '{dto.Identifier}' already exists for provider '{dto.Provider}'"); + } + + var identifier = new ModelProviderTypeAssociation + { + ModelId = id, + Identifier = dto.Identifier, + Provider = providerType, + IsPrimary = dto.IsPrimary ?? false, + Metadata = dto.Metadata, + MaxInputTokens = dto.MaxInputTokens, + MaxOutputTokens = dto.MaxOutputTokens, + SpeedScore = dto.SpeedScore, + QualityScore = dto.QualityScore, + ProviderVariation = dto.ProviderVariation + }; + + model.Identifiers.Add(identifier); + await _modelRepository.UpdateModelAsync(model); + + LogAdminAudit("Created", "ModelIdentifier", identifier.Id, + $"ModelId: {id}, Identifier: {LoggingSanitizer.S(dto.Identifier)}"); + + return CreatedAtAction(nameof(GetModelIdentifiers), new { id }, new + { + id = identifier.Id, + identifier = identifier.Identifier, + provider = (int?)identifier.Provider, + isPrimary = identifier.IsPrimary, + maxInputTokens = identifier.MaxInputTokens, + maxOutputTokens = identifier.MaxOutputTokens, + speedScore = identifier.SpeedScore, + qualityScore = identifier.QualityScore, + providerVariation = identifier.ProviderVariation + }); + }, + result => result, + "CreateModelIdentifier", + new { Id = id }); + } + + /// + /// Updates a model identifier + /// + /// The model ID + /// The identifier ID + /// The updated identifier data + /// No content on success + [HttpPut("{id}/identifiers/{identifierId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public Task UpdateModelIdentifier(int id, int identifierId, [FromBody] UpdateModelIdentifierDto dto) + { + return ExecuteAsync( + async () => + { + var model = await _modelRepository.GetByIdWithDetailsAsync(id); + if (model == null) + { + return (IActionResult)NotFound($"Model with ID {id} not found"); + } + + var identifier = model.Identifiers.FirstOrDefault(i => i.Id == identifierId); + if (identifier == null) + { + return NotFound($"Identifier with ID {identifierId} not found for model {id}"); + } + + // Parse provider if provided as integer + ProviderType? providerType = dto.Provider.HasValue ? (ProviderType)dto.Provider.Value : null; + + // Check if the new identifier/provider combo already exists (if changed) + if (identifier.Identifier != dto.Identifier || identifier.Provider != providerType) + { + var existing = model.Identifiers.FirstOrDefault(i => + i.Id != identifierId && + i.Identifier == dto.Identifier && + i.Provider == providerType); + + if (existing != null) + { + return Conflict($"Identifier '{dto.Identifier}' already exists for provider '{dto.Provider}'"); + } + } + + identifier.Identifier = dto.Identifier; + identifier.Provider = providerType; + identifier.IsPrimary = dto.IsPrimary ?? identifier.IsPrimary; + identifier.Metadata = dto.Metadata; + identifier.MaxInputTokens = dto.MaxInputTokens; + identifier.MaxOutputTokens = dto.MaxOutputTokens; + identifier.SpeedScore = dto.SpeedScore; + identifier.QualityScore = dto.QualityScore; + identifier.ProviderVariation = dto.ProviderVariation; + + await _modelRepository.UpdateModelAsync(model); + + LogAdminAudit("Updated", "ModelIdentifier", identifierId, + $"ModelId: {id}, Identifier: {LoggingSanitizer.S(dto.Identifier)}"); + + return (IActionResult)NoContent(); + }, + result => result, + "UpdateModelIdentifier", + new { Id = id, IdentifierId = identifierId }); + } + + /// + /// Deletes a model identifier + /// + /// The model ID + /// The identifier ID to delete + /// No content on success + [HttpDelete("{id}/identifiers/{identifierId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task DeleteModelIdentifier(int id, int identifierId) + { + return ExecuteAsync( + async () => + { + // Directly delete the identifier from the repository + var deleted = await _modelRepository.DeleteIdentifierAsync(id, identifierId); + + if (!deleted) + { + throw new KeyNotFoundException($"Identifier with ID {identifierId} not found for model {id}"); + } + + LogAdminAudit("Deleted", "ModelIdentifier", identifierId, $"ModelId: {id}"); + }, + NoContent(), + "DeleteModelIdentifier", + new { Id = id, IdentifierId = identifierId }); + } + } +} diff --git a/Services/ConduitLLM.Admin/Controllers/ModelController.ProviderMappings.cs b/Services/ConduitLLM.Admin/Controllers/ModelController.ProviderMappings.cs new file mode 100644 index 000000000..fc4fa9469 --- /dev/null +++ b/Services/ConduitLLM.Admin/Controllers/ModelController.ProviderMappings.cs @@ -0,0 +1,206 @@ +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace ConduitLLM.Admin.Controllers +{ + public partial class ModelController + { + /// + /// Gets all provider mappings for a specific model + /// + /// The model ID + /// List of provider mappings for the model + [HttpGet("{id}/provider-mappings")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task GetModelProviderMappings(int id) + { + return ExecuteWithNotFoundAsync( + () => _modelRepository.GetByIdAsync(id), + async model => + { + // Get all mappings for this model + var mappings = await _mappingService.GetMappingsByModelIdAsync(id); + var dtos = mappings.Select(m => m.ToDto()); + + return (IActionResult)Ok(dtos); + }, + "Model", id, "GetModelProviderMappings"); + } + + /// + /// Creates a new provider mapping for a specific model + /// + /// The model ID + /// The provider mapping to create + /// The created provider mapping + [HttpPost("{id}/provider-mappings")] + [ProducesResponseType(typeof(ModelProviderMappingDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task CreateModelProviderMapping(int id, [FromBody] ModelProviderMappingDto mappingDto) + { + return ExecuteAsync( + async () => + { + // Skip ModelId validation since it's no longer on the DTO + // The ModelProviderTypeAssociationId provides the model relationship + + // Check if model exists + var model = await _modelRepository.GetByIdAsync(id); + if (model == null) + { + return (IActionResult)NotFound($"Model with ID {id} not found"); + } + + // Check for duplicate mapping + var existingMappings = await _mappingService.GetMappingsByModelIdAsync(id); + if (existingMappings.Any(m => m.ProviderId == mappingDto.ProviderId)) + { + return Conflict($"A mapping for model ID {id} with provider ID {mappingDto.ProviderId} already exists"); + } + + // Create the mapping + var mapping = mappingDto.ToEntity(); + var success = await _mappingService.AddMappingAsync(mapping); + + if (!success) + { + return BadRequest("Failed to create provider mapping"); + } + + // Get the created mapping + var createdMappings = await _mappingService.GetMappingsByModelIdAsync(id); + var createdMapping = createdMappings.FirstOrDefault(m => m.ProviderId == mappingDto.ProviderId); + + LogAdminAudit("Created", "ModelProviderMapping", createdMapping?.Id, + $"ModelId: {id}, ProviderId: {mappingDto.ProviderId}"); + + return CreatedAtAction( + nameof(GetModelProviderMappings), + new { id = id }, + createdMapping?.ToDto() + ); + }, + result => result, + "CreateModelProviderMapping", + new { Id = id }); + } + + /// + /// Updates a provider mapping for a specific model + /// + /// The model ID + /// The mapping ID + /// The updated provider mapping data + /// No content on success + [HttpPut("{id}/provider-mappings/{mappingId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task UpdateModelProviderMapping(int id, int mappingId, [FromBody] ModelProviderMappingDto mappingDto) + { + if (mappingDto.Id != mappingId) + { + return Task.FromResult(BadRequest("Mapping ID in URL does not match Mapping ID in request body")); + } + + return ExecuteAsync( + async () => + { + // Skip ModelId validation since it's no longer on the DTO + // The ModelProviderTypeAssociationId provides the model relationship + + // Check if model exists + var model = await _modelRepository.GetByIdAsync(id); + if (model == null) + { + return (IActionResult)NotFound($"Model with ID {id} not found"); + } + + // Get and update the mapping + var existingMapping = await _mappingService.GetMappingByIdAsync(mappingId); + if (existingMapping == null) + { + return NotFound($"Provider mapping with ID {mappingId} not found"); + } + + if (existingMapping.ModelProviderTypeAssociation?.ModelId != id) + { + return BadRequest($"Mapping with ID {mappingId} does not belong to model with ID {id}"); + } + + existingMapping.UpdateFromDto(mappingDto); + var success = await _mappingService.UpdateMappingAsync(existingMapping); + + if (!success) + { + return BadRequest("Failed to update provider mapping"); + } + + LogAdminAudit("Updated", "ModelProviderMapping", mappingId, $"ModelId: {id}"); + + return (IActionResult)NoContent(); + }, + result => result, + "UpdateModelProviderMapping", + new { Id = id, MappingId = mappingId }); + } + + /// + /// Deletes a provider mapping for a specific model + /// + /// The model ID + /// The mapping ID to delete + /// No content on success + [HttpDelete("{id}/provider-mappings/{mappingId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task DeleteModelProviderMapping(int id, int mappingId) + { + return ExecuteAsync( + async () => + { + // Check if model exists + var model = await _modelRepository.GetByIdAsync(id); + if (model == null) + { + return (IActionResult)NotFound($"Model with ID {id} not found"); + } + + // Check if mapping exists and belongs to this model + var existingMapping = await _mappingService.GetMappingByIdAsync(mappingId); + if (existingMapping == null) + { + return NotFound($"Provider mapping with ID {mappingId} not found"); + } + + if (existingMapping.ModelProviderTypeAssociation?.ModelId != id) + { + return BadRequest($"Mapping with ID {mappingId} does not belong to model with ID {id}"); + } + + var success = await _mappingService.DeleteMappingAsync(mappingId); + + if (!success) + { + return BadRequest("Failed to delete provider mapping"); + } + + LogAdminAudit("Deleted", "ModelProviderMapping", mappingId, $"ModelId: {id}"); + + return (IActionResult)NoContent(); + }, + result => result, + "DeleteModelProviderMapping", + new { Id = id, MappingId = mappingId }); + } + } +} diff --git a/Services/ConduitLLM.Admin/Controllers/ModelController.cs b/Services/ConduitLLM.Admin/Controllers/ModelController.cs index 2c4f18a7e..c4590277a 100644 --- a/Services/ConduitLLM.Admin/Controllers/ModelController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ModelController.cs @@ -1,6 +1,8 @@ +using ConduitLLM.Admin.Extensions; using ConduitLLM.Admin.Models.Models; using ConduitLLM.Admin.Models.ModelSeries; using ConduitLLM.Admin.Models.ModelCapabilities; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Repositories; @@ -9,6 +11,7 @@ using ConduitLLM.Configuration.Extensions; using ConduitLLM.Admin.Interfaces; using ConduitLLM.Core.Events; +using ConduitLLM.Core.Extensions; using MassTransit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -23,13 +26,12 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class ModelController : ControllerBase + public partial class ModelController : AdminControllerBase { private readonly IModelRepository _modelRepository; private readonly IAdminModelProviderMappingService _mappingService; private readonly IProviderRepository _providerRepository; private readonly IPublishEndpoint _publishEndpoint; - private readonly ILogger _logger; /// /// Initializes a new instance of the ModelController @@ -40,34 +42,69 @@ public ModelController( IProviderRepository providerRepository, IPublishEndpoint publishEndpoint, ILogger logger) + : base(publishEndpoint, logger) { _modelRepository = modelRepository ?? throw new ArgumentNullException(nameof(modelRepository)); _mappingService = mappingService ?? throw new ArgumentNullException(nameof(mappingService)); _providerRepository = providerRepository ?? throw new ArgumentNullException(nameof(providerRepository)); _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - /// Gets all models with their capabilities + /// Gets all models with their capabilities. + /// Supports optional server-side pagination, search, and filtering. + /// When page/pageSize are omitted, returns all models (backward compatible). /// - /// List of all models + /// Page number (1-based). Required together with pageSize for pagination. + /// Items per page (max 100). Required together with page for pagination. + /// Optional search term for model name (case-insensitive partial match) + /// Optional capability filter: chat, vision, image, video, embeddings + /// Optional filter: true = only models with identifiers, false = without + /// List of all models, or paginated result when page/pageSize are provided [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllModels() + public Task GetAllModels( + [FromQuery] int? page = null, + [FromQuery] int? pageSize = null, + [FromQuery] string? search = null, + [FromQuery] string? capability = null, + [FromQuery] bool? hasProviders = null) { - try + // Clamp pagination parameters + if (page.HasValue && page.Value < 1) page = 1; + if (pageSize.HasValue) { - var models = await _modelRepository.GetAllWithDetailsAsync(); - var dtos = models.Select(m => MapToDto(m)); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all models"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving models"); + if (pageSize.Value < 1) pageSize = 50; + if (pageSize.Value > 100) pageSize = 100; } + + return ExecuteAsync( + async () => + { + var (models, totalCount) = await _modelRepository.GetPaginatedWithFilterAsync( + page, pageSize, search, capability, hasProviders); + + var dtos = models.Select(m => m.ToDto()).ToList(); + + // Return paginated result when pagination params are provided + if (page.HasValue && pageSize.HasValue) + { + return (object)new Configuration.DTOs.PagedResult + { + Items = dtos, + TotalCount = totalCount, + CurrentPage = page.Value, + PageSize = pageSize.Value, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize.Value) + }; + } + + // Backward compatible: return flat array + return dtos; + }, + result => Ok(result), + "GetAllModels"); } /// @@ -79,23 +116,12 @@ public async Task GetAllModels() [ProducesResponseType(typeof(ModelDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelById(int id) + public Task GetModelById(int id) { - try - { - var model = await _modelRepository.GetByIdWithDetailsAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } - - return Ok(MapToDto(model)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving the model"); - } + return ExecuteWithNotFoundAsync( + () => _modelRepository.GetByIdWithDetailsAsync(id), + model => Ok(model.ToDto()), + "Model", id, "GetModelById"); } @@ -107,24 +133,21 @@ public async Task GetModelById(int id) [HttpGet("search")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task SearchModels([FromQuery] string query) + public Task SearchModels([FromQuery] string query) { - try - { - if (string.IsNullOrWhiteSpace(query)) + return ExecuteAsync( + async () => { - return Ok(new List()); - } + if (string.IsNullOrWhiteSpace(query)) + { + return (object)new List(); + } - var models = await _modelRepository.SearchByNameAsync(query); - var dtos = models.Select(m => MapToDto(m)); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error searching models with query {Query}", query); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while searching models"); - } + var models = await _modelRepository.SearchByNameAsync(query); + return models.Select(m => m.ToDto()); + }, + result => Ok(result), + "SearchModels"); } /// @@ -136,424 +159,136 @@ public async Task SearchModels([FromQuery] string query) [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelsByProvider(string provider) + public Task GetModelsByProvider(string provider) { - try + if (string.IsNullOrWhiteSpace(provider)) { - if (string.IsNullOrWhiteSpace(provider)) - { - return BadRequest("Provider name is required"); - } - - // Parse provider string to enum - if (!Enum.TryParse(provider, ignoreCase: true, out var providerType)) - { - var validProviders = Enum.GetNames() - .Select(p => p.ToLowerInvariant()); - return BadRequest($"Invalid provider '{provider}'. Valid providers: {string.Join(", ", validProviders)}"); - } - - var models = await _modelRepository.GetByProviderAsync(providerType); - var dtos = models.Select(m => - { - // Repository already handles the provider string to enum conversion - // Just get the first identifier for this model (they're already filtered by provider) - var providerIdentifier = m.Identifiers?.FirstOrDefault()?.Identifier - ?? m.Name; // Fallback to model name if no specific identifier - - // Use MapToDto to get base DTO, then create extended DTO - var baseDto = MapToDto(m); - return new ModelWithProviderIdDto - { - Id = baseDto.Id, - Name = baseDto.Name, - ProviderModelId = providerIdentifier, - ModelSeriesId = baseDto.ModelSeriesId, - IsActive = baseDto.IsActive, - CreatedAt = baseDto.CreatedAt, - UpdatedAt = baseDto.UpdatedAt, - Series = baseDto.Series, - ModelParameters = baseDto.ModelParameters, - // Copy capability fields - SupportsChat = baseDto.SupportsChat, - SupportsVision = baseDto.SupportsVision, - SupportsFunctionCalling = baseDto.SupportsFunctionCalling, - SupportsStreaming = baseDto.SupportsStreaming, - SupportsImageGeneration = baseDto.SupportsImageGeneration, - SupportsVideoGeneration = baseDto.SupportsVideoGeneration, - SupportsEmbeddings = baseDto.SupportsEmbeddings, - MaxInputTokens = baseDto.MaxInputTokens, - MaxOutputTokens = baseDto.MaxOutputTokens, - TokenizerType = baseDto.TokenizerType - }; - }); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting models for provider {Provider}", provider); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving models"); + return Task.FromResult(BadRequest("Provider name is required")); } - } - - /// - /// Gets model identifiers for a specific model - /// - /// The model ID - /// List of model identifiers showing which providers offer this model - [HttpGet("{id}/identifiers")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelIdentifiers(int id) - { - try - { - var model = await _modelRepository.GetByIdWithDetailsAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } - var identifiers = model.Identifiers.Select(i => new - { - id = i.Id, - identifier = i.Identifier, - provider = (int?)i.Provider, - isPrimary = i.IsPrimary, - maxInputTokens = i.MaxInputTokens, - maxOutputTokens = i.MaxOutputTokens, - speedScore = i.SpeedScore, - qualityScore = i.QualityScore, - providerVariation = i.ProviderVariation, - modelCostId = i.ModelCostId - }); - - return Ok(identifiers); - } - catch (Exception ex) + // Parse provider string to enum + if (!Enum.TryParse(provider, ignoreCase: true, out var providerType)) { - _logger.LogError(ex, "Error getting identifiers for model with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving model identifiers"); + var validProviders = Enum.GetNames() + .Select(p => p.ToLowerInvariant()); + return Task.FromResult(BadRequest($"Invalid provider '{provider}'. Valid providers: {string.Join(", ", validProviders)}")); } - } - /// - /// Gets model associations with available providers - /// Returns only associations where matching providers are configured - /// - /// The model ID - /// List of associations with their available providers - [HttpGet("{id}/available-providers")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAvailableProviders(int id) - { - try - { - var model = await _modelRepository.GetByIdWithDetailsAsync(id); - if (model == null) + return ExecuteAsync( + async () => { - return NotFound($"Model with ID {id} not found"); - } - - var providers = await _providerRepository.GetAllAsync(); - var enabledProviders = providers.Where(p => p.IsEnabled).ToList(); - - var result = new List(); - - foreach (var association in model.Identifiers) - { - // Skip associations without a provider type - they're not properly configured - if (association.Provider == null) + var models = await _modelRepository.GetByProviderAsync(providerType); + return models.Select(m => { - _logger.LogWarning( - "ModelIdentifier {AssociationId} for model {ModelId} has null Provider field - skipping", - association.Id, id); - continue; - } - - // Find matching providers for this association - var matchingProviders = enabledProviders.Where(p => - p.ProviderType == association.Provider - ).ToList(); - - if (matchingProviders.Any()) - { - result.Add(new + // Repository already handles the provider string to enum conversion + // Just get the first identifier for this model (they're already filtered by provider) + var providerIdentifier = m.Identifiers?.FirstOrDefault()?.Identifier + ?? m.Name; // Fallback to model name if no specific identifier + + // Use MapToDto to get base DTO, then create extended DTO + var baseDto = m.ToDto(); + return new ModelWithProviderIdDto { - associationId = association.Id, - identifier = association.Identifier, - provider = (int?)association.Provider, - providerVariation = association.ProviderVariation, - maxInputTokens = association.MaxInputTokens, - maxOutputTokens = association.MaxOutputTokens, - speedScore = association.SpeedScore, - qualityScore = association.QualityScore, - isPrimary = association.IsPrimary, - availableProviders = matchingProviders.Select(p => new - { - providerId = p.Id, - providerName = p.ProviderName, - providerType = p.ProviderType.ToString() - }) - }); - } - } - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting available providers for model with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving available providers"); - } + Id = baseDto.Id, + Name = baseDto.Name, + ProviderModelId = providerIdentifier, + ModelSeriesId = baseDto.ModelSeriesId, + IsActive = baseDto.IsActive, + CreatedAt = baseDto.CreatedAt, + UpdatedAt = baseDto.UpdatedAt, + Series = baseDto.Series, + ModelParameters = baseDto.ModelParameters, + // Copy capability fields + SupportsChat = baseDto.SupportsChat, + SupportsVision = baseDto.SupportsVision, + SupportsFunctionCalling = baseDto.SupportsFunctionCalling, + SupportsStreaming = baseDto.SupportsStreaming, + SupportsImageGeneration = baseDto.SupportsImageGeneration, + SupportsVideoGeneration = baseDto.SupportsVideoGeneration, + SupportsEmbeddings = baseDto.SupportsEmbeddings, + MaxInputTokens = baseDto.MaxInputTokens, + MaxOutputTokens = baseDto.MaxOutputTokens, + TokenizerType = baseDto.TokenizerType + }; + }); + }, + result => Ok(result), + "GetModelsByProvider", + new { Provider = provider }); } /// - /// Creates a new model identifier for a specific model + /// Creates a new model /// - /// The model ID - /// The identifier data - /// The created identifier - [HttpPost("{id}/identifiers")] - [ProducesResponseType(typeof(object), StatusCodes.Status201Created)] + /// The model to create + /// The created model + [HttpPost] + [ProducesResponseType(typeof(ModelDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task CreateModelIdentifier(int id, [FromBody] CreateModelIdentifierDto dto) + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task CreateModel([FromBody] CreateModelDto dto) { - try - { - var model = await _modelRepository.GetByIdWithDetailsAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } - - // Parse provider if provided as integer - ProviderType? providerType = dto.Provider.HasValue ? (ProviderType)dto.Provider.Value : null; - - // Check if identifier already exists for this provider - var existing = model.Identifiers.FirstOrDefault(i => - i.Identifier == dto.Identifier && - i.Provider == providerType); - - if (existing != null) - { - return Conflict($"Identifier '{dto.Identifier}' already exists for provider '{dto.Provider}'"); - } - - var identifier = new ModelProviderTypeAssociation - { - ModelId = id, - Identifier = dto.Identifier, - Provider = providerType, - IsPrimary = dto.IsPrimary ?? false, - Metadata = dto.Metadata, - MaxInputTokens = dto.MaxInputTokens, - MaxOutputTokens = dto.MaxOutputTokens, - SpeedScore = dto.SpeedScore, - QualityScore = dto.QualityScore, - ProviderVariation = dto.ProviderVariation - }; - - model.Identifiers.Add(identifier); - await _modelRepository.UpdateAsync(model); - - return CreatedAtAction(nameof(GetModelIdentifiers), new { id }, new - { - id = identifier.Id, - identifier = identifier.Identifier, - provider = (int?)identifier.Provider, - isPrimary = identifier.IsPrimary, - maxInputTokens = identifier.MaxInputTokens, - maxOutputTokens = identifier.MaxOutputTokens, - speedScore = identifier.SpeedScore, - qualityScore = identifier.QualityScore, - providerVariation = identifier.ProviderVariation - }); - } - catch (Exception ex) + if (dto == null) { - _logger.LogError(ex, "Error creating identifier for model {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating the identifier"); + return Task.FromResult(BadRequest("Model data is required")); } - } - /// - /// Updates a model identifier - /// - /// The model ID - /// The identifier ID - /// The updated identifier data - /// No content on success - [HttpPut("{id}/identifiers/{identifierId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task UpdateModelIdentifier(int id, int identifierId, [FromBody] UpdateModelIdentifierDto dto) - { - try + if (string.IsNullOrWhiteSpace(dto.Name)) { - var model = await _modelRepository.GetByIdWithDetailsAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } + return Task.FromResult(BadRequest("Model name is required")); + } - var identifier = model.Identifiers.FirstOrDefault(i => i.Id == identifierId); - if (identifier == null) + return ExecuteAsync( + async () => { - return NotFound($"Identifier with ID {identifierId} not found for model {id}"); - } - - // Parse provider if provided as integer - ProviderType? providerType = dto.Provider.HasValue ? (ProviderType)dto.Provider.Value : null; - - // Check if the new identifier/provider combo already exists (if changed) - if (identifier.Identifier != dto.Identifier || identifier.Provider != providerType) - { - var existing = model.Identifiers.FirstOrDefault(i => - i.Id != identifierId && - i.Identifier == dto.Identifier && - i.Provider == providerType); - + // Check if a model with the same name already exists + var existing = await _modelRepository.GetByNameAsync(dto.Name); if (existing != null) { - return Conflict($"Identifier '{dto.Identifier}' already exists for provider '{dto.Provider}'"); + return (IActionResult)Conflict($"A model with name '{dto.Name}' already exists"); } - } - identifier.Identifier = dto.Identifier; - identifier.Provider = providerType; - identifier.IsPrimary = dto.IsPrimary ?? identifier.IsPrimary; - identifier.Metadata = dto.Metadata; - identifier.MaxInputTokens = dto.MaxInputTokens; - identifier.MaxOutputTokens = dto.MaxOutputTokens; - identifier.SpeedScore = dto.SpeedScore; - identifier.QualityScore = dto.QualityScore; - identifier.ProviderVariation = dto.ProviderVariation; - - await _modelRepository.UpdateAsync(model); - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating identifier {IdentifierId} for model {Id}", identifierId, id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while updating the identifier"); - } - } - - /// - /// Deletes a model identifier - /// - /// The model ID - /// The identifier ID to delete - /// No content on success - [HttpDelete("{id}/identifiers/{identifierId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteModelIdentifier(int id, int identifierId) - { - try - { - // Directly delete the identifier from the repository - var deleted = await _modelRepository.DeleteIdentifierAsync(id, identifierId); - - if (!deleted) - { - return NotFound($"Identifier with ID {identifierId} not found for model {id}"); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting identifier {IdentifierId} for model {Id}", identifierId, id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while deleting the identifier"); - } - } - - /// - /// Creates a new model - /// - /// The model to create - /// The created model - [HttpPost] - [ProducesResponseType(typeof(ModelDto), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateModel([FromBody] CreateModelDto dto) - { - try - { - if (dto == null) - { - return BadRequest("Model data is required"); - } - - if (string.IsNullOrWhiteSpace(dto.Name)) - { - return BadRequest("Model name is required"); - } + var model = new Model + { + Name = dto.Name, + ModelSeriesId = dto.ModelSeriesId, + ModelParameters = dto.ModelParameters, + IsActive = dto.IsActive ?? true, + // Set capability fields directly + SupportsChat = dto.SupportsChat, + SupportsVision = dto.SupportsVision, + SupportsFunctionCalling = dto.SupportsFunctionCalling, + SupportsStreaming = dto.SupportsStreaming, + SupportsImageGeneration = dto.SupportsImageGeneration, + SupportsVideoGeneration = dto.SupportsVideoGeneration, + SupportsEmbeddings = dto.SupportsEmbeddings, + MaxInputTokens = dto.MaxInputTokens, + MaxOutputTokens = dto.MaxOutputTokens, + TokenizerType = dto.TokenizerType, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } + await _modelRepository.CreateModelAsync(model); - // Check if a model with the same name already exists - var existing = await _modelRepository.GetByNameAsync(dto.Name); - if (existing != null) - { - return Conflict($"A model with name '{dto.Name}' already exists"); - } + // Reload with capabilities + model = await _modelRepository.GetByIdWithDetailsAsync(model.Id); + if (model == null) + { + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to reload created model"); + } - var model = new Model - { - Name = dto.Name, - ModelSeriesId = dto.ModelSeriesId, - ModelParameters = dto.ModelParameters, - IsActive = dto.IsActive ?? true, - // Set capability fields directly - SupportsChat = dto.SupportsChat, - SupportsVision = dto.SupportsVision, - SupportsFunctionCalling = dto.SupportsFunctionCalling, - SupportsStreaming = dto.SupportsStreaming, - SupportsImageGeneration = dto.SupportsImageGeneration, - SupportsVideoGeneration = dto.SupportsVideoGeneration, - SupportsEmbeddings = dto.SupportsEmbeddings, - MaxInputTokens = dto.MaxInputTokens, - MaxOutputTokens = dto.MaxOutputTokens, - TokenizerType = dto.TokenizerType, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - await _modelRepository.CreateAsync(model); - - // Reload with capabilities - model = await _modelRepository.GetByIdWithDetailsAsync(model.Id); - if (model == null) - { - return StatusCode(StatusCodes.Status500InternalServerError, "Failed to reload created model"); - } + LogAdminAudit("Created", "Model", model.Id, $"Name: {LoggingSanitizer.S(model.Name)}"); + AdminOperationsMetricsService.RecordConfigurationChange("model", "create"); - return CreatedAtAction( - nameof(GetModelById), - new { id = model.Id }, - MapToDto(model)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating model"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating the model"); - } + return CreatedAtAction( + nameof(GetModelById), + new { id = model.Id }, + model.ToDto()); + }, + result => result, + "CreateModel"); } /// @@ -568,383 +303,206 @@ public async Task CreateModel([FromBody] CreateModelDto dto) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateModel(int id, [FromBody] UpdateModelDto dto) + public Task UpdateModel(int id, [FromBody] UpdateModelDto dto) { - try + if (dto == null) { - if (dto == null) - { - return BadRequest("Update data is required"); - } + return Task.FromResult(BadRequest("Update data is required")); + } - if (!ModelState.IsValid) + return ExecuteAsync( + async () => { - return BadRequest(ModelState); - } + var model = await _modelRepository.GetByIdWithDetailsAsync(id); + if (model == null) + { + return (IActionResult)NotFound($"Model with ID {id} not found"); + } - var model = await _modelRepository.GetByIdWithDetailsAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } + // Capture pre-state for change tracking + var changes = new List<(string Property, string? OldValue, string? NewValue)>(); - // Check for name conflicts if name is being changed - if (!string.IsNullOrEmpty(dto.Name) && dto.Name != model.Name) - { - var existing = await _modelRepository.GetByNameAsync(dto.Name); - if (existing != null && existing.Id != id) + // Check for name conflicts if name is being changed + if (!string.IsNullOrEmpty(dto.Name) && dto.Name != model.Name) { - return Conflict($"A model with name '{dto.Name}' already exists"); + var existing = await _modelRepository.GetByNameAsync(dto.Name); + if (existing != null && existing.Id != id) + { + return Conflict($"A model with name '{dto.Name}' already exists"); + } + changes.Add(("Name", model.Name, dto.Name)); + model.Name = dto.Name; } - model.Name = dto.Name; - } - - if (dto.ModelSeriesId.HasValue) - model.ModelSeriesId = dto.ModelSeriesId.Value; - if (dto.IsActive.HasValue) - model.IsActive = dto.IsActive.Value; - if (dto.ModelParameters != null) - model.ModelParameters = string.IsNullOrWhiteSpace(dto.ModelParameters) ? null : dto.ModelParameters; - - // Update capability fields - if (dto.SupportsChat.HasValue) - model.SupportsChat = dto.SupportsChat.Value; - if (dto.SupportsVision.HasValue) - model.SupportsVision = dto.SupportsVision.Value; - if (dto.SupportsFunctionCalling.HasValue) - model.SupportsFunctionCalling = dto.SupportsFunctionCalling.Value; - if (dto.SupportsStreaming.HasValue) - model.SupportsStreaming = dto.SupportsStreaming.Value; - if (dto.SupportsImageGeneration.HasValue) - model.SupportsImageGeneration = dto.SupportsImageGeneration.Value; - if (dto.SupportsVideoGeneration.HasValue) - model.SupportsVideoGeneration = dto.SupportsVideoGeneration.Value; - if (dto.SupportsEmbeddings.HasValue) - model.SupportsEmbeddings = dto.SupportsEmbeddings.Value; - // For nullable int fields, we need to handle them differently - // The DTO will have the property set if it was included in the JSON - // We always update these fields since the frontend always sends them - model.MaxInputTokens = dto.MaxInputTokens; - model.MaxOutputTokens = dto.MaxOutputTokens; - - model.UpdatedAt = DateTime.UtcNow; - - // Track if parameters were changed - bool parametersChanged = dto.ModelParameters != null; - - var updatedModel = await _modelRepository.UpdateAsync(model); - - // Publish ModelUpdated event for cache invalidation - await _publishEndpoint.Publish(new ModelUpdated - { - ModelId = updatedModel.Id, - ModelName = updatedModel.Name, - ModelSeriesId = updatedModel.ModelSeriesId, - ChangeType = "Updated", - ParametersChanged = parametersChanged, - ChangedProperties = GetChangedProperties(dto) - }); - - _logger.LogInformation("Published ModelUpdated event for model {ModelId} ({ModelName})", - updatedModel.Id, updatedModel.Name); - - return Ok(MapToDto(updatedModel)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating model with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while updating the model"); - } - } - /// - /// Deletes a model - /// - /// The model ID - /// No content on success - [HttpDelete("{id}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteModel(int id) - { - try - { - var model = await _modelRepository.GetByIdAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } + if (dto.ModelSeriesId.HasValue && model.ModelSeriesId != dto.ModelSeriesId.Value) + { + changes.Add(("ModelSeriesId", model.ModelSeriesId.ToString(), dto.ModelSeriesId.Value.ToString())); + model.ModelSeriesId = dto.ModelSeriesId.Value; + } + else if (dto.ModelSeriesId.HasValue) + { + model.ModelSeriesId = dto.ModelSeriesId.Value; + } - // Check if model is referenced by any mappings - var hasReferences = await _modelRepository.HasMappingReferencesAsync(id); - if (hasReferences) - { - return Conflict("Cannot delete model that is referenced by model provider mappings"); - } + if (dto.IsActive.HasValue && model.IsActive != dto.IsActive.Value) + { + changes.Add(("IsActive", model.IsActive.ToString(), dto.IsActive.Value.ToString())); + model.IsActive = dto.IsActive.Value; + } + else if (dto.IsActive.HasValue) + { + model.IsActive = dto.IsActive.Value; + } - await _modelRepository.DeleteAsync(id); + if (dto.ModelParameters != null) + { + var newParams = string.IsNullOrWhiteSpace(dto.ModelParameters) ? null : dto.ModelParameters; + if (model.ModelParameters != newParams) + changes.Add(("ModelParameters", model.ModelParameters ?? "null", newParams ?? "null")); + model.ModelParameters = newParams; + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting model with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while deleting the model"); - } - } + // Update capability fields with change tracking + if (dto.SupportsChat.HasValue) + { + if (model.SupportsChat != dto.SupportsChat.Value) + changes.Add(("SupportsChat", model.SupportsChat.ToString(), dto.SupportsChat.Value.ToString())); + model.SupportsChat = dto.SupportsChat.Value; + } + if (dto.SupportsVision.HasValue) + { + if (model.SupportsVision != dto.SupportsVision.Value) + changes.Add(("SupportsVision", model.SupportsVision.ToString(), dto.SupportsVision.Value.ToString())); + model.SupportsVision = dto.SupportsVision.Value; + } + if (dto.SupportsFunctionCalling.HasValue) + { + if (model.SupportsFunctionCalling != dto.SupportsFunctionCalling.Value) + changes.Add(("SupportsFunctionCalling", model.SupportsFunctionCalling.ToString(), dto.SupportsFunctionCalling.Value.ToString())); + model.SupportsFunctionCalling = dto.SupportsFunctionCalling.Value; + } + if (dto.SupportsStreaming.HasValue) + { + if (model.SupportsStreaming != dto.SupportsStreaming.Value) + changes.Add(("SupportsStreaming", model.SupportsStreaming.ToString(), dto.SupportsStreaming.Value.ToString())); + model.SupportsStreaming = dto.SupportsStreaming.Value; + } + if (dto.SupportsImageGeneration.HasValue) + { + if (model.SupportsImageGeneration != dto.SupportsImageGeneration.Value) + changes.Add(("SupportsImageGeneration", model.SupportsImageGeneration.ToString(), dto.SupportsImageGeneration.Value.ToString())); + model.SupportsImageGeneration = dto.SupportsImageGeneration.Value; + } + if (dto.SupportsVideoGeneration.HasValue) + { + if (model.SupportsVideoGeneration != dto.SupportsVideoGeneration.Value) + changes.Add(("SupportsVideoGeneration", model.SupportsVideoGeneration.ToString(), dto.SupportsVideoGeneration.Value.ToString())); + model.SupportsVideoGeneration = dto.SupportsVideoGeneration.Value; + } + if (dto.SupportsEmbeddings.HasValue) + { + if (model.SupportsEmbeddings != dto.SupportsEmbeddings.Value) + changes.Add(("SupportsEmbeddings", model.SupportsEmbeddings.ToString(), dto.SupportsEmbeddings.Value.ToString())); + model.SupportsEmbeddings = dto.SupportsEmbeddings.Value; + } - private static ModelDto MapToDto(Model model) - { - // Map model with embedded capabilities - return new ModelDto - { - Id = model.Id, - Name = model.Name, - ModelSeriesId = model.ModelSeriesId, - IsActive = model.IsActive, - CreatedAt = model.CreatedAt, - UpdatedAt = model.UpdatedAt, - Series = model.Series != null ? MapSeriesToDto(model.Series) : null, - ModelParameters = model.ModelParameters, - // Capability fields embedded directly - SupportsChat = model.SupportsChat, - SupportsVision = model.SupportsVision, - SupportsImageGeneration = model.SupportsImageGeneration, - SupportsVideoGeneration = model.SupportsVideoGeneration, - SupportsEmbeddings = model.SupportsEmbeddings, - SupportsFunctionCalling = model.SupportsFunctionCalling, - SupportsStreaming = model.SupportsStreaming, - MaxInputTokens = model.MaxInputTokens, - MaxOutputTokens = model.MaxOutputTokens, - TokenizerType = model.TokenizerType - }; - } + // For nullable int fields, always update since frontend always sends them + if (model.MaxInputTokens != dto.MaxInputTokens) + changes.Add(("MaxInputTokens", model.MaxInputTokens?.ToString() ?? "null", dto.MaxInputTokens?.ToString() ?? "null")); + model.MaxInputTokens = dto.MaxInputTokens; - private static ModelSeriesDto MapSeriesToDto(ModelSeries series) - { - return new ModelSeriesDto - { - Id = series.Id, - AuthorId = series.AuthorId, - AuthorName = series.Author?.Name, - Name = series.Name, - Description = series.Description, - TokenizerType = series.TokenizerType, - Parameters = series.Parameters - }; - } + if (model.MaxOutputTokens != dto.MaxOutputTokens) + changes.Add(("MaxOutputTokens", model.MaxOutputTokens?.ToString() ?? "null", dto.MaxOutputTokens?.ToString() ?? "null")); + model.MaxOutputTokens = dto.MaxOutputTokens; + model.UpdatedAt = DateTime.UtcNow; - /// - /// Gets all provider mappings for a specific model - /// - /// The model ID - /// List of provider mappings for the model - [HttpGet("{id}/provider-mappings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelProviderMappings(int id) - { - try - { - // Check if model exists - var model = await _modelRepository.GetByIdAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } - - // Get all mappings for this model - var mappings = await _mappingService.GetMappingsByModelIdAsync(id); - var dtos = mappings.Select(m => m.ToDto()); - - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting provider mappings for model with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving provider mappings"); - } - } - - /// - /// Creates a new provider mapping for a specific model - /// - /// The model ID - /// The provider mapping to create - /// The created provider mapping - [HttpPost("{id}/provider-mappings")] - [ProducesResponseType(typeof(ModelProviderMappingDto), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateModelProviderMapping(int id, [FromBody] ModelProviderMappingDto mappingDto) - { - try - { - // Skip ModelId validation since it's no longer on the DTO - // The ModelProviderTypeAssociationId provides the model relationship + // Track if parameters were changed + bool parametersChanged = dto.ModelParameters != null; - // Check if model exists - var model = await _modelRepository.GetByIdAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } + var updatedModel = await _modelRepository.UpdateModelAsync(model); - // Check for duplicate mapping - var existingMappings = await _mappingService.GetMappingsByModelIdAsync(id); - if (existingMappings.Any(m => m.ProviderId == mappingDto.ProviderId)) - { - return Conflict($"A mapping for model ID {id} with provider ID {mappingDto.ProviderId} already exists"); - } + // Publish ModelUpdated event for cache invalidation + var changedPropertyNames = changes.Count > 0 + ? changes.Select(c => c.Property).ToArray() + : GetChangedProperties(dto); - // Create the mapping - var mapping = mappingDto.ToEntity(); - var success = await _mappingService.AddMappingAsync(mapping); + await _publishEndpoint.Publish(new ModelUpdated + { + ModelId = updatedModel.Id, + ModelName = updatedModel.Name, + ModelSeriesId = updatedModel.ModelSeriesId, + ChangeType = "Updated", + ParametersChanged = parametersChanged, + ChangedProperties = changedPropertyNames + }); + + if (changes.Count > 0) + { + LogAdminAuditWithChanges("Model", updatedModel.Id, changes, + $"Name: {LoggingSanitizer.S(updatedModel.Name)}"); + } + else + { + LogAdminAudit("Updated", "Model", updatedModel.Id, + $"Name: {LoggingSanitizer.S(updatedModel.Name)}, no value changes detected"); + } + AdminOperationsMetricsService.RecordConfigurationChange("model", "update"); - if (!success) - { - return BadRequest("Failed to create provider mapping"); - } - - // Get the created mapping - var createdMappings = await _mappingService.GetMappingsByModelIdAsync(id); - var createdMapping = createdMappings.FirstOrDefault(m => m.ProviderId == mappingDto.ProviderId); - - return CreatedAtAction( - nameof(GetModelProviderMappings), - new { id = id }, - createdMapping?.ToDto() - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating provider mapping for model with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating the provider mapping"); - } + return (IActionResult)Ok(updatedModel.ToDto()); + }, + result => result, + "UpdateModel", + new { Id = id }); } /// - /// Updates a provider mapping for a specific model + /// Deletes a model /// /// The model ID - /// The mapping ID - /// The updated provider mapping data /// No content on success - [HttpPut("{id}/provider-mappings/{mappingId}")] + [HttpDelete("{id}")] [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateModelProviderMapping(int id, int mappingId, [FromBody] ModelProviderMappingDto mappingDto) + public Task DeleteModel(int id) { - try - { - // Skip ModelId validation since it's no longer on the DTO - // The ModelProviderTypeAssociationId provides the model relationship - - if (mappingDto.Id != mappingId) - { - return BadRequest("Mapping ID in URL does not match Mapping ID in request body"); - } - - // Check if model exists - var model = await _modelRepository.GetByIdAsync(id); - if (model == null) + return ExecuteAsync( + async () => { - return NotFound($"Model with ID {id} not found"); - } - - // Get and update the mapping - var existingMapping = await _mappingService.GetMappingByIdAsync(mappingId); - if (existingMapping == null) - { - return NotFound($"Provider mapping with ID {mappingId} not found"); - } + var model = await _modelRepository.GetByIdAsync(id); + if (model == null) + { + return (IActionResult)NotFound($"Model with ID {id} not found"); + } - if (existingMapping.ModelProviderTypeAssociation?.ModelId != id) - { - return BadRequest($"Mapping with ID {mappingId} does not belong to model with ID {id}"); - } + // Check if model is referenced by any mappings + var hasReferences = await _modelRepository.HasMappingReferencesAsync(id); + if (hasReferences) + { + return Conflict("Cannot delete model that is referenced by model provider mappings"); + } - existingMapping.UpdateFromDto(mappingDto); - var success = await _mappingService.UpdateMappingAsync(existingMapping); + await _modelRepository.DeleteAsync(id); - if (!success) - { - return BadRequest("Failed to update provider mapping"); - } + LogAdminAudit("Deleted", "Model", id); + AdminOperationsMetricsService.RecordConfigurationChange("model", "delete"); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating provider mapping {MappingId} for model {ModelId}", mappingId, id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while updating the provider mapping"); - } + return (IActionResult)NoContent(); + }, + result => result, + "DeleteModel", + new { Id = id }); } - /// - /// Deletes a provider mapping for a specific model - /// - /// The model ID - /// The mapping ID to delete - /// No content on success - [HttpDelete("{id}/provider-mappings/{mappingId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteModelProviderMapping(int id, int mappingId) - { - try - { - // Check if model exists - var model = await _modelRepository.GetByIdAsync(id); - if (model == null) - { - return NotFound($"Model with ID {id} not found"); - } - - // Check if mapping exists and belongs to this model - var existingMapping = await _mappingService.GetMappingByIdAsync(mappingId); - if (existingMapping == null) - { - return NotFound($"Provider mapping with ID {mappingId} not found"); - } - - if (existingMapping.ModelProviderTypeAssociation?.ModelId != id) - { - return BadRequest($"Mapping with ID {mappingId} does not belong to model with ID {id}"); - } - - var success = await _mappingService.DeleteMappingAsync(mappingId); - - if (!success) - { - return BadRequest("Failed to delete provider mapping"); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting provider mapping {MappingId} for model {ModelId}", mappingId, id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while deleting the provider mapping"); - } - } - /// /// Helper method to get list of changed properties from DTO /// private static string[] GetChangedProperties(UpdateModelDto dto) { var changedProps = new List(); - + if (dto.Name != null) changedProps.Add("Name"); if (dto.ModelSeriesId.HasValue) changedProps.Add("ModelSeriesId"); if (dto.IsActive.HasValue) changedProps.Add("IsActive"); @@ -958,8 +516,8 @@ private static string[] GetChangedProperties(UpdateModelDto dto) if (dto.SupportsEmbeddings.HasValue) changedProps.Add("SupportsEmbeddings"); if (dto.MaxInputTokens.HasValue) changedProps.Add("MaxInputTokens"); if (dto.MaxOutputTokens.HasValue) changedProps.Add("MaxOutputTokens"); - + return changedProps.ToArray(); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Controllers/ModelCostsController.cs b/Services/ConduitLLM.Admin/Controllers/ModelCostsController.cs index e6091a7c7..e11215a1b 100644 --- a/Services/ConduitLLM.Admin/Controllers/ModelCostsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ModelCostsController.cs @@ -17,11 +17,10 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class ModelCostsController : ControllerBase + public class ModelCostsController : AdminControllerBase { private readonly IAdminModelCostService _modelCostService; private readonly IPricingRulesValidator _pricingRulesValidator; - private readonly ILogger _logger; /// /// Initializes a new instance of the ModelCostsController @@ -33,10 +32,10 @@ public ModelCostsController( IAdminModelCostService modelCostService, IPricingRulesValidator pricingRulesValidator, ILogger logger) + : base(logger) { _modelCostService = modelCostService ?? throw new ArgumentNullException(nameof(modelCostService)); _pricingRulesValidator = pricingRulesValidator ?? throw new ArgumentNullException(nameof(pricingRulesValidator)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -49,51 +48,47 @@ public ModelCostsController( [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllModelCosts( + public Task GetAllModelCosts( [FromQuery] int? page = null, [FromQuery] int? pageSize = null, [FromQuery] string? modelType = null) { - try - { - var modelCosts = await _modelCostService.GetAllModelCostsAsync(); - - // Apply modelType filter if provided - if (!string.IsNullOrWhiteSpace(modelType)) + return ExecuteAsync( + async () => { - modelCosts = modelCosts.Where(c => - string.Equals(c.ModelType, modelType, StringComparison.OrdinalIgnoreCase)); - } + var modelCosts = await _modelCostService.GetAllModelCostsAsync(); - // If pagination parameters are provided, return paginated response - if (page.HasValue && pageSize.HasValue) - { - var totalCount = modelCosts.Count(); - var items = modelCosts - .Skip((page.Value - 1) * pageSize.Value) - .Take(pageSize.Value) - .ToList(); + // Apply modelType filter if provided + if (!string.IsNullOrWhiteSpace(modelType)) + { + modelCosts = modelCosts.Where(c => + string.Equals(c.ModelType, modelType, StringComparison.OrdinalIgnoreCase)); + } - var paginatedResponse = new + // If pagination parameters are provided, return paginated response + if (page.HasValue && pageSize.HasValue) { - items = items, - totalCount = totalCount, - page = page.Value, - pageSize = pageSize.Value, - totalPages = (int)Math.Ceiling(totalCount / (double)pageSize.Value) - }; - - return Ok(paginatedResponse); - } - - // Otherwise return all items (backward compatibility) - return Ok(modelCosts); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all model costs"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var totalCount = modelCosts.Count(); + var items = modelCosts + .Skip((page.Value - 1) * pageSize.Value) + .Take(pageSize.Value) + .ToList(); + + return (object)new + { + items = items, + totalCount = totalCount, + page = page.Value, + pageSize = pageSize.Value, + totalPages = (int)Math.Ceiling(totalCount / (double)pageSize.Value) + }; + } + + // Otherwise return all items (backward compatibility) + return (object)modelCosts; + }, + result => Ok(result), + "GetAllModelCosts"); } /// @@ -105,24 +100,12 @@ public async Task GetAllModelCosts( [ProducesResponseType(typeof(ModelCostDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelCostById(int id) + public Task GetModelCostById(int id) { - try - { - var modelCost = await _modelCostService.GetModelCostByIdAsync(id); - - if (modelCost == null) - { - return NotFound(new ErrorResponseDto("Model cost not found")); - } - - return Ok(modelCost); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model cost with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _modelCostService.GetModelCostByIdAsync(id), + Ok, + "Model cost", id, "GetModelCostById"); } /// @@ -133,18 +116,13 @@ public async Task GetModelCostById(int id) [HttpGet("provider/{providerId}")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelCostsByProvider(int providerId) + public Task GetModelCostsByProvider(int providerId) { - try - { - var modelCosts = await _modelCostService.GetModelCostsByProviderAsync(providerId); - return Ok(modelCosts); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model costs for provider {ProviderId}", providerId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _modelCostService.GetModelCostsByProviderAsync(providerId), + result => Ok(result), + "GetModelCostsByProvider", + new { ProviderId = providerId }); } /// @@ -156,24 +134,12 @@ public async Task GetModelCostsByProvider(int providerId) [ProducesResponseType(typeof(ModelCostDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelCostByCostName(string costName) + public Task GetModelCostByCostName(string costName) { - try - { - var modelCost = await _modelCostService.GetModelCostByCostNameAsync(costName); - - if (modelCost == null) - { - return NotFound(new ErrorResponseDto("Model cost not found")); - } - - return Ok(modelCost); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model cost with name '{CostName}'", LoggingSanitizer.S(costName)); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _modelCostService.GetModelCostByCostNameAsync(costName), + Ok, + "Model cost", costName, "GetModelCostByCostName"); } /// @@ -185,28 +151,17 @@ public async Task GetModelCostByCostName(string costName) [ProducesResponseType(typeof(ModelCostDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateModelCost([FromBody] CreateModelCostDto modelCost) + public Task CreateModelCost([FromBody] CreateModelCostDto modelCost) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var createdModelCost = await _modelCostService.CreateModelCostAsync(modelCost); - return CreatedAtAction(nameof(GetModelCostById), new { id = createdModelCost.Id }, createdModelCost); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation when creating model cost"); - return BadRequest(ex.Message); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating model cost"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var result = await _modelCostService.CreateModelCostAsync(modelCost); + LogAdminAudit("Created", "ModelCost", result.Id, $"CostName: {LoggingSanitizer.S(result.CostName)}"); + return result; + }, + result => CreatedAtAction(nameof(GetModelCostById), new { id = result.Id }, result), + "CreateModelCost"); } /// @@ -220,40 +175,29 @@ public async Task CreateModelCost([FromBody] CreateModelCostDto m [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateModelCost(int id, [FromBody] UpdateModelCostDto modelCost) + public Task UpdateModelCost(int id, [FromBody] UpdateModelCostDto modelCost) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - // Ensure ID in route matches ID in body if (id != modelCost.Id) { - return BadRequest("ID in route must match ID in body"); + return Task.FromResult(BadRequest("ID in route must match ID in body")); } - try - { - var success = await _modelCostService.UpdateModelCostAsync(modelCost); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Model cost not found")); - } + var success = await _modelCostService.UpdateModelCostAsync(modelCost); - return NoContent(); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation when updating model cost"); - return BadRequest(ex.Message); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating model cost with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!success) + { + throw new KeyNotFoundException($"Model cost with ID '{id}' not found"); + } + + LogAdminAudit("Updated", "ModelCost", id, $"CostName: {LoggingSanitizer.S(modelCost.CostName)}"); + }, + NoContent(), + "UpdateModelCost", + new { Id = id }); } /// @@ -265,24 +209,24 @@ public async Task UpdateModelCost(int id, [FromBody] UpdateModelC [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteModelCost(int id) + public Task DeleteModelCost(int id) { - try - { - var success = await _modelCostService.DeleteModelCostAsync(id); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Model cost not found")); - } + var existing = await _modelCostService.GetModelCostByIdAsync(id); + var success = await _modelCostService.DeleteModelCostAsync(id); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting model cost with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!success) + { + throw new KeyNotFoundException($"Model cost with ID '{id}' not found"); + } + + LogAdminAudit("Deleted", "ModelCost", id, existing != null ? $"CostName: {LoggingSanitizer.S(existing.CostName)}" : null); + }, + NoContent(), + "DeleteModelCost", + new { Id = id }); } /// @@ -295,26 +239,20 @@ public async Task DeleteModelCost(int id) [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelCostOverview( + public Task GetModelCostOverview( [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { if (startDate > endDate) { - return BadRequest("Start date cannot be after end date"); + return Task.FromResult(BadRequest("Start date cannot be after end date")); } - try - { - var overview = await _modelCostService.GetModelCostOverviewAsync(startDate, endDate); - return Ok(overview); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model cost overview for period {StartDate} to {EndDate}", - startDate, endDate); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _modelCostService.GetModelCostOverviewAsync(startDate, endDate), + result => Ok(result), + "GetModelCostOverview", + new { StartDate = startDate, EndDate = endDate }); } /// @@ -326,23 +264,22 @@ public async Task GetModelCostOverview( [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ImportModelCosts([FromBody] IEnumerable modelCosts) + public Task ImportModelCosts([FromBody] IEnumerable modelCosts) { - if (modelCosts == null || modelCosts.Count() == 0) + if (modelCosts == null || !modelCosts.Any()) { - return BadRequest("No model costs provided for import"); + return Task.FromResult(BadRequest("No model costs provided for import")); } - try - { - var importedCount = await _modelCostService.ImportModelCostsAsync(modelCosts); - return Ok(importedCount); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error importing model costs"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var result = await _modelCostService.ImportModelCostsAsync(modelCosts); + LogAdminAudit("Imported", "ModelCost", detail: $"Count: {result}"); + return result; + }, + result => Ok(result), + "ImportModelCosts"); } /// @@ -353,21 +290,17 @@ public async Task ImportModelCosts([FromBody] IEnumerable ExportCsv([FromQuery] int? providerId = null) + public Task ExportCsv([FromQuery] int? providerId = null) { - try - { - var csvData = await _modelCostService.ExportModelCostsAsync("csv", providerId); - var bytes = Encoding.UTF8.GetBytes(csvData); - var fileName = $"model-costs-{DateTime.UtcNow:yyyy-MM-dd-HHmmss}.csv"; - - return File(bytes, "text/csv", fileName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error exporting model costs as CSV"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _modelCostService.ExportModelCostsAsync("csv", providerId), + result => + { + var bytes = Encoding.UTF8.GetBytes(result); + var fileName = $"model-costs-{DateTime.UtcNow:yyyy-MM-dd-HHmmss}.csv"; + return File(bytes, "text/csv", fileName); + }, + "ExportCsv"); } /// @@ -378,21 +311,17 @@ public async Task ExportCsv([FromQuery] int? providerId = null) [HttpGet("export/json")] [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ExportJson([FromQuery] int? providerId = null) + public Task ExportJson([FromQuery] int? providerId = null) { - try - { - var jsonData = await _modelCostService.ExportModelCostsAsync("json", providerId); - var bytes = Encoding.UTF8.GetBytes(jsonData); - var fileName = $"model-costs-{DateTime.UtcNow:yyyy-MM-dd-HHmmss}.json"; - - return File(bytes, "application/json", fileName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error exporting model costs as JSON"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _modelCostService.ExportModelCostsAsync("json", providerId), + result => + { + var bytes = Encoding.UTF8.GetBytes(result); + var fileName = $"model-costs-{DateTime.UtcNow:yyyy-MM-dd-HHmmss}.json"; + return File(bytes, "application/json", fileName); + }, + "ExportJson"); } /// @@ -404,42 +333,42 @@ public async Task ExportJson([FromQuery] int? providerId = null) // [ProducesResponseType(typeof(BulkImportResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ImportCsv(IFormFile file) + public Task ImportCsv(IFormFile file) { if (file == null || file.Length == 0) { - return BadRequest(new ErrorResponseDto("No file provided for import")); + return Task.FromResult(BadRequest(new ErrorResponseDto("No file provided for import"))); } if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) { - return BadRequest(new ErrorResponseDto("File must be a CSV file")); + return Task.FromResult(BadRequest(new ErrorResponseDto("File must be a CSV file"))); } - try - { - using var reader = new StreamReader(file.OpenReadStream()); - var csvData = await reader.ReadToEndAsync(); - - var result = await _modelCostService.ImportModelCostsAsync(csvData, "csv"); - - if (result.SuccessCount == 0 && result.FailureCount > 0) + return ExecuteAsync( + async () => { - return BadRequest(new { - message = "Import failed", - errors = result.Errors, - successCount = result.SuccessCount, - failureCount = result.FailureCount - }); - } - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error importing model costs from CSV"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + using var reader = new StreamReader(file.OpenReadStream()); + var csvData = await reader.ReadToEndAsync(); + + var result = await _modelCostService.ImportModelCostsAsync(csvData, "csv"); + + if (result.SuccessCount == 0 && result.FailureCount > 0) + { + throw new InvalidOperationException( + System.Text.Json.JsonSerializer.Serialize(new { + message = "Import failed", + errors = result.Errors, + successCount = result.SuccessCount, + failureCount = result.FailureCount + })); + } + + LogAdminAuditBulk("ImportedCsv", "ModelCost", result.SuccessCount, result.FailureCount); + return result; + }, + result => Ok(result), + "ImportCsv"); } /// @@ -451,42 +380,42 @@ public async Task ImportCsv(IFormFile file) // [ProducesResponseType(typeof(BulkImportResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ImportJson(IFormFile file) + public Task ImportJson(IFormFile file) { if (file == null || file.Length == 0) { - return BadRequest("No file provided for import"); + return Task.FromResult(BadRequest("No file provided for import")); } if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { - return BadRequest("File must be a JSON file"); + return Task.FromResult(BadRequest("File must be a JSON file")); } - try - { - using var reader = new StreamReader(file.OpenReadStream()); - var jsonData = await reader.ReadToEndAsync(); - - var result = await _modelCostService.ImportModelCostsAsync(jsonData, "json"); - - if (result.SuccessCount == 0 && result.FailureCount > 0) + return ExecuteAsync( + async () => { - return BadRequest(new { - message = "Import failed", - errors = result.Errors, - successCount = result.SuccessCount, - failureCount = result.FailureCount - }); - } - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error importing model costs from JSON"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + using var reader = new StreamReader(file.OpenReadStream()); + var jsonData = await reader.ReadToEndAsync(); + + var result = await _modelCostService.ImportModelCostsAsync(jsonData, "json"); + + if (result.SuccessCount == 0 && result.FailureCount > 0) + { + throw new InvalidOperationException( + System.Text.Json.JsonSerializer.Serialize(new { + message = "Import failed", + errors = result.Errors, + successCount = result.SuccessCount, + failureCount = result.FailureCount + })); + } + + LogAdminAuditBulk("ImportedJson", "ModelCost", result.SuccessCount, result.FailureCount); + return result; + }, + result => Ok(result), + "ImportJson"); } /// @@ -500,43 +429,40 @@ public async Task ImportJson(IFormFile file) [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ValidatePricingRules( + public Task ValidatePricingRules( int id, [FromBody] ValidatePricingRulesRequest request) { if (request == null || string.IsNullOrWhiteSpace(request.PricingConfiguration)) { - return BadRequest(new ErrorResponseDto("Pricing configuration is required")); + return Task.FromResult(BadRequest(new ErrorResponseDto("Pricing configuration is required"))); } - try - { - // Verify the model cost exists - var modelCost = await _modelCostService.GetModelCostByIdAsync(id); - if (modelCost == null) - { - return NotFound(new ErrorResponseDto("Model cost not found")); - } - - // Get parameter schema from associated model if available - string? parameterSchema = null; - if (!string.IsNullOrEmpty(request.ParameterSchema)) + return ExecuteAsync( + async () => { - // Use provided schema (for testing or when model schema is known) - parameterSchema = request.ParameterSchema; - } - // TODO: In the future, we could look up the model's parameter schema from ModelSeries - - // Validate the configuration - var result = _pricingRulesValidator.ValidateJson(request.PricingConfiguration, parameterSchema); + // Verify the model cost exists + var modelCost = await _modelCostService.GetModelCostByIdAsync(id); + if (modelCost == null) + { + throw new KeyNotFoundException($"Model cost with ID '{id}' not found"); + } - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error validating pricing rules for model cost {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + // Get parameter schema from associated model if available + string? parameterSchema = null; + if (!string.IsNullOrEmpty(request.ParameterSchema)) + { + // Use provided schema (for testing or when model schema is known) + parameterSchema = request.ParameterSchema; + } + // TODO: In the future, we could look up the model's parameter schema from ModelSeries + + // Validate the configuration + return _pricingRulesValidator.ValidateJson(request.PricingConfiguration, parameterSchema); + }, + result => Ok(result), + "ValidatePricingRules", + new { Id = id }); } /// @@ -548,26 +474,23 @@ public async Task ValidatePricingRules( [ProducesResponseType(typeof(ValidationResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult ValidatePricingRulesStandalone([FromBody] ValidatePricingRulesRequest request) + public Task ValidatePricingRulesStandalone([FromBody] ValidatePricingRulesRequest request) { if (request == null || string.IsNullOrWhiteSpace(request.PricingConfiguration)) { - return BadRequest(new ErrorResponseDto("Pricing configuration is required")); + return Task.FromResult(BadRequest(new ErrorResponseDto("Pricing configuration is required"))); } - try - { - var result = _pricingRulesValidator.ValidateJson( - request.PricingConfiguration, - request.ParameterSchema); - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error validating pricing rules"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => + { + var result = _pricingRulesValidator.ValidateJson( + request.PricingConfiguration, + request.ParameterSchema); + return Task.FromResult(result); + }, + result => Ok(result), + "ValidatePricingRulesStandalone"); } } diff --git a/Services/ConduitLLM.Admin/Controllers/ModelProviderMappingController.cs b/Services/ConduitLLM.Admin/Controllers/ModelProviderMappingController.cs index 9a9276cd2..70ad2e5ec 100644 --- a/Services/ConduitLLM.Admin/Controllers/ModelProviderMappingController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ModelProviderMappingController.cs @@ -1,9 +1,11 @@ using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Core.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,11 +18,10 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class ModelProviderMappingController : ControllerBase +public class ModelProviderMappingController : AdminControllerBase { private readonly IAdminModelProviderMappingService _mappingService; private readonly IProviderService _providerService; - private readonly ILogger _logger; /// /// Initializes a new instance of the ModelProviderMappingController @@ -32,10 +33,10 @@ public ModelProviderMappingController( IAdminModelProviderMappingService mappingService, IProviderService providerService, ILogger logger) + : base(logger) { _mappingService = mappingService ?? throw new ArgumentNullException(nameof(mappingService)); _providerService = providerService ?? throw new ArgumentNullException(nameof(providerService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -45,19 +46,16 @@ public ModelProviderMappingController( [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllMappings() + public Task GetAllMappings() { - try - { - var mappings = await _mappingService.GetAllMappingsAsync(); - var dtos = mappings.Select(m => m.ToDto()); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all model provider mappings"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving model provider mappings"); - } + return ExecuteAsync( + async () => + { + var mappings = await _mappingService.GetAllMappingsAsync(); + return mappings.Select(m => m.ToDto()); + }, + result => Ok(result), + "GetAllMappings"); } /// @@ -69,24 +67,12 @@ public async Task GetAllMappings() [ProducesResponseType(typeof(ModelProviderMappingDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetMappingById(int id) + public Task GetMappingById(int id) { - try - { - var mapping = await _mappingService.GetMappingByIdAsync(id); - - if (mapping == null) - { - return NotFound(new ErrorResponseDto("Model provider mapping not found")); - } - - return Ok(mapping.ToDto()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model provider mapping with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving the model provider mapping"); - } + return ExecuteWithNotFoundAsync( + () => _mappingService.GetMappingByIdAsync(id), + mapping => Ok(mapping.ToDto()), + "Model provider mapping", id, "GetMappingById"); } /// @@ -99,39 +85,38 @@ public async Task GetMappingById(int id) [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateMapping([FromBody] ModelProviderMappingDto mappingDto) + public Task CreateMapping([FromBody] ModelProviderMappingDto mappingDto) { - try - { - if (!ModelState.IsValid) + return ExecuteAsync( + async () => { - return BadRequest(ModelState); - } + // Check if a mapping with the same model alias already exists + var existingMappings = await _mappingService.GetAllMappingsAsync(); + var existingMapping = existingMappings.FirstOrDefault(m => m.ModelAlias.Equals(mappingDto.ModelAlias, StringComparison.OrdinalIgnoreCase)); + if (existingMapping != null) + { + return (IActionResult)Conflict(new ErrorResponseDto($"A mapping for model alias '{mappingDto.ModelAlias}' already exists")); + } - // Check if a mapping with the same model alias already exists - var existingMappings = await _mappingService.GetAllMappingsAsync(); - var existingMapping = existingMappings.FirstOrDefault(m => m.ModelAlias.Equals(mappingDto.ModelAlias, StringComparison.OrdinalIgnoreCase)); - if (existingMapping != null) - { - return Conflict(new ErrorResponseDto($"A mapping for model alias '{mappingDto.ModelAlias}' already exists")); - } + var mapping = mappingDto.ToEntity(); + var success = await _mappingService.AddMappingAsync(mapping); - var mapping = mappingDto.ToEntity(); - var success = await _mappingService.AddMappingAsync(mapping); + if (!success) + { + return BadRequest(new ErrorResponseDto("Failed to create model provider mapping. Please check the provider ID.")); + } - if (!success) - { - return BadRequest(new ErrorResponseDto("Failed to create model provider mapping. Please check the provider ID.")); - } + var createdMapping = await _mappingService.GetMappingByIdAsync(mapping.Id); - var createdMapping = await _mappingService.GetMappingByIdAsync(mapping.Id); - return CreatedAtAction(nameof(GetMappingById), new { id = createdMapping?.Id }, createdMapping?.ToDto()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating model provider mapping"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating the model provider mapping"); - } + LogAdminAudit("Created", "ModelProviderMapping", createdMapping?.Id, + $"ModelAlias: {LoggingSanitizer.S(mappingDto.ModelAlias)}, ProviderId: {mappingDto.ProviderId}"); + AdminOperationsMetricsService.RecordModelMappingOperation("create", "success"); + AdminOperationsMetricsService.RecordConfigurationChange("modelmapping", "create"); + + return CreatedAtAction(nameof(GetMappingById), new { id = createdMapping?.Id }, createdMapping?.ToDto()); + }, + result => result, + "CreateMapping"); } /// @@ -145,41 +130,37 @@ public async Task CreateMapping([FromBody] ModelProviderMappingDt [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateMapping(int id, [FromBody] ModelProviderMappingDto mappingDto) + public Task UpdateMapping(int id, [FromBody] ModelProviderMappingDto mappingDto) { - try + if (id != mappingDto.Id) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - if (id != mappingDto.Id) - { - return BadRequest(new ErrorResponseDto("ID mismatch")); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("ID mismatch"))); + } - var existingMapping = await _mappingService.GetMappingByIdAsync(id); - if (existingMapping == null) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Model provider mapping not found")); - } + var existingMapping = await _mappingService.GetMappingByIdAsync(id); + if (existingMapping == null) + { + throw new KeyNotFoundException($"Model provider mapping with ID '{id}' not found"); + } - existingMapping.UpdateFromDto(mappingDto); - var success = await _mappingService.UpdateMappingAsync(existingMapping); + existingMapping.UpdateFromDto(mappingDto); + var success = await _mappingService.UpdateMappingAsync(existingMapping); - if (!success) - { - return BadRequest(new ErrorResponseDto("Failed to update model provider mapping")); - } + if (!success) + { + throw new InvalidOperationException("Failed to update model provider mapping"); + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating model provider mapping with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while updating the model provider mapping"); - } + LogAdminAudit("Updated", "ModelProviderMapping", id); + AdminOperationsMetricsService.RecordModelMappingOperation("update", "success"); + AdminOperationsMetricsService.RecordConfigurationChange("modelmapping", "update"); + }, + NoContent(), + "UpdateMapping", + new { Id = id }); } /// @@ -191,30 +172,31 @@ public async Task UpdateMapping(int id, [FromBody] ModelProviderM [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteMapping(int id) + public Task DeleteMapping(int id) { - try - { - var existingMapping = await _mappingService.GetMappingByIdAsync(id); - if (existingMapping == null) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Model provider mapping not found")); - } + var existingMapping = await _mappingService.GetMappingByIdAsync(id); + if (existingMapping == null) + { + throw new KeyNotFoundException($"Model provider mapping with ID '{id}' not found"); + } - var success = await _mappingService.DeleteMappingAsync(id); + var success = await _mappingService.DeleteMappingAsync(id); - if (!success) - { - return BadRequest(new ErrorResponseDto("Failed to delete model provider mapping")); - } + if (!success) + { + throw new InvalidOperationException("Failed to delete model provider mapping"); + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting model provider mapping with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while deleting the model provider mapping"); - } + LogAdminAudit("Deleted", "ModelProviderMapping", id); + AdminOperationsMetricsService.RecordModelMappingOperation("delete", "success"); + AdminOperationsMetricsService.RecordConfigurationChange("modelmapping", "delete"); + }, + NoContent(), + "DeleteMapping", + new { Id = id }); } /// @@ -224,18 +206,12 @@ public async Task DeleteMapping(int id) [HttpGet("providers")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetProviders() + public Task GetProviders() { - try - { - var providers = await _mappingService.GetProvidersAsync(); - return Ok(providers); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting providers"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving providers"); - } + return ExecuteAsync( + () => _mappingService.GetProvidersAsync(), + result => Ok(result), + "GetProviders"); } /// @@ -247,39 +223,35 @@ public async Task GetProviders() [ProducesResponseType(typeof(BulkMappingResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateBulkMappings([FromBody] List mappingDtos) + public Task CreateBulkMappings([FromBody] List mappingDtos) { - try + if (mappingDtos == null || !mappingDtos.Any()) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } + return Task.FromResult(BadRequest(new ErrorResponseDto("No mappings provided"))); + } - if (mappingDtos == null || mappingDtos.Count() == 0) + return ExecuteAsync( + async () => { - return BadRequest(new ErrorResponseDto("No mappings provided")); - } - - var mappings = mappingDtos.Select(dto => dto.ToEntity()).ToList(); - var (created, errors) = await _mappingService.CreateBulkMappingsAsync(mappings); + var mappings = mappingDtos.Select(dto => dto.ToEntity()).ToList(); + var (created, errors) = await _mappingService.CreateBulkMappingsAsync(mappings); - var result = new BulkMappingResult - { - Created = created.Select(m => m.ToDto()).ToList(), - Errors = errors.ToList(), - TotalProcessed = mappingDtos.Count(), - SuccessCount = created.Count(), - FailureCount = errors.Count() - }; - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating bulk model provider mappings"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating bulk model provider mappings"); - } + var result = new BulkMappingResult + { + Created = created.Select(m => m.ToDto()).ToList(), + Errors = errors.ToList(), + TotalProcessed = mappingDtos.Count(), + SuccessCount = created.Count(), + FailureCount = errors.Count() + }; + + LogAdminAuditBulk("BulkCreated", "ModelProviderMapping", result.SuccessCount, result.FailureCount); + AdminOperationsMetricsService.RecordModelMappingOperation("bulk_create", "success"); + + return result; + }, + result => Ok(result), + "CreateBulkMappings"); } /// @@ -291,62 +263,63 @@ public async Task CreateBulkMappings([FromBody] List DeleteBulkMappings([FromBody] List ids) + public Task DeleteBulkMappings([FromBody] List ids) { - try + if (ids == null || ids.Count == 0) { - if (ids == null || ids.Count == 0) - { - return BadRequest(new ErrorResponseDto("No mapping IDs provided")); - } - - var deleted = new List(); - var errors = new List(); + return Task.FromResult(BadRequest(new ErrorResponseDto("No mapping IDs provided"))); + } - foreach (var id in ids) + return ExecuteAsync( + async () => { - try - { - var existingMapping = await _mappingService.GetMappingByIdAsync(id); - if (existingMapping == null) - { - errors.Add($"Mapping with ID {id} not found"); - continue; - } + var deleted = new List(); + var errors = new List(); - var success = await _mappingService.DeleteMappingAsync(id); - if (success) + foreach (var id in ids) + { + try { - deleted.Add(id); + var existingMapping = await _mappingService.GetMappingByIdAsync(id); + if (existingMapping == null) + { + errors.Add($"Mapping with ID {id} not found"); + continue; + } + + var success = await _mappingService.DeleteMappingAsync(id); + if (success) + { + deleted.Add(id); + } + else + { + errors.Add($"Failed to delete mapping with ID {id}"); + } } - else + catch (Exception ex) { - errors.Add($"Failed to delete mapping with ID {id}"); + Logger.LogError(ex, "Error deleting mapping with ID {Id}", id); + errors.Add($"Error deleting mapping with ID {id}: {ex.Message}"); } } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting mapping with ID {Id}", id); - errors.Add($"Error deleting mapping with ID {id}: {ex.Message}"); - } - } - var result = new BulkDeleteResult - { - DeletedIds = deleted, - Errors = errors, - TotalProcessed = ids.Count, - SuccessCount = deleted.Count, - FailureCount = errors.Count - }; - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting bulk model provider mappings"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while deleting bulk model provider mappings"); - } + var result = new BulkDeleteResult + { + DeletedIds = deleted, + Errors = errors, + TotalProcessed = ids.Count, + SuccessCount = deleted.Count, + FailureCount = errors.Count + }; + + LogAdminAuditBulk("BulkDeleted", "ModelProviderMapping", result.SuccessCount, result.FailureCount); + AdminOperationsMetricsService.RecordModelMappingOperation("bulk_delete", "success"); + + return result; + }, + result => Ok(result), + "DeleteBulkMappings"); } /// @@ -377,64 +350,65 @@ public async Task DisableBulkMappings([FromBody] List ids) return await UpdateBulkMappingsStatus(ids, false); } - private async Task UpdateBulkMappingsStatus(List ids, bool isEnabled) + private Task UpdateBulkMappingsStatus(List ids, bool isEnabled) { - try + if (ids == null || ids.Count == 0) { - if (ids == null || ids.Count == 0) - { - return BadRequest(new ErrorResponseDto("No mapping IDs provided")); - } - - var updated = new List(); - var errors = new List(); + return Task.FromResult(BadRequest(new ErrorResponseDto("No mapping IDs provided"))); + } - foreach (var id in ids) + return ExecuteAsync( + async () => { - try - { - var existingMapping = await _mappingService.GetMappingByIdAsync(id); - if (existingMapping == null) - { - errors.Add($"Mapping with ID {id} not found"); - continue; - } + var updated = new List(); + var errors = new List(); - existingMapping.IsEnabled = isEnabled; - var success = await _mappingService.UpdateMappingAsync(existingMapping); - - if (success) + foreach (var id in ids) + { + try { - updated.Add(existingMapping.ToDto()); + var existingMapping = await _mappingService.GetMappingByIdAsync(id); + if (existingMapping == null) + { + errors.Add($"Mapping with ID {id} not found"); + continue; + } + + existingMapping.IsEnabled = isEnabled; + var success = await _mappingService.UpdateMappingAsync(existingMapping); + + if (success) + { + updated.Add(existingMapping.ToDto()); + } + else + { + errors.Add($"Failed to update mapping with ID {id}"); + } } - else + catch (Exception ex) { - errors.Add($"Failed to update mapping with ID {id}"); + Logger.LogError(ex, "Error updating mapping with ID {Id}", id); + errors.Add($"Error updating mapping with ID {id}: {ex.Message}"); } } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating mapping with ID {Id}", id); - errors.Add($"Error updating mapping with ID {id}: {ex.Message}"); - } - } - var result = new BulkUpdateResult - { - Updated = updated, - Errors = errors, - TotalProcessed = ids.Count, - SuccessCount = updated.Count, - FailureCount = errors.Count - }; - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating bulk model provider mappings status"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while updating bulk model provider mappings"); - } + var result = new BulkUpdateResult + { + Updated = updated, + Errors = errors, + TotalProcessed = ids.Count, + SuccessCount = updated.Count, + FailureCount = errors.Count + }; + + LogAdminAuditBulk(isEnabled ? "BulkEnabled" : "BulkDisabled", "ModelProviderMapping", result.SuccessCount, result.FailureCount); + AdminOperationsMetricsService.RecordModelMappingOperation(isEnabled ? "bulk_enable" : "bulk_disable", "success"); + + return result; + }, + result => Ok(result), + "UpdateBulkMappingsStatus"); } } diff --git a/Services/ConduitLLM.Admin/Controllers/ModelSeriesController.cs b/Services/ConduitLLM.Admin/Controllers/ModelSeriesController.cs index 3a143d664..4fc5f82d5 100644 --- a/Services/ConduitLLM.Admin/Controllers/ModelSeriesController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ModelSeriesController.cs @@ -1,6 +1,8 @@ +using ConduitLLM.Admin.Extensions; using ConduitLLM.Admin.Models.ModelSeries; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Repositories; +using ConduitLLM.Core.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,10 +15,9 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class ModelSeriesController : ControllerBase + public class ModelSeriesController : AdminControllerBase { private readonly IModelSeriesRepository _repository; - private readonly ILogger _logger; /// /// Initializes a new instance of the ModelSeriesController @@ -24,9 +25,9 @@ public class ModelSeriesController : ControllerBase public ModelSeriesController( IModelSeriesRepository repository, ILogger logger) + : base(logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -36,19 +37,16 @@ public ModelSeriesController( [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAll() + public Task GetAll() { - try - { - var series = await _repository.GetAllWithAuthorAsync(); - var dtos = series.Select(s => MapToDto(s)); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all model series"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving model series"); - } + return ExecuteAsync( + async () => + { + var series = await _repository.GetAllWithAuthorAsync(); + return series.Select(s => s.ToDto()); + }, + Ok, + "GetAll"); } /// @@ -60,23 +58,14 @@ public async Task GetAll() [ProducesResponseType(typeof(ModelSeriesDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetById(int id) + public Task GetById(int id) { - try - { - var series = await _repository.GetByIdWithAuthorAsync(id); - if (series == null) - { - return NotFound($"Model series with ID {id} not found"); - } - - return Ok(MapToDto(series)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model series with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving the model series"); - } + return ExecuteWithNotFoundAsync( + () => _repository.GetByIdWithAuthorAsync(id), + series => Ok(series.ToDto()), + "Model series", + id, + "GetById"); } /// @@ -88,31 +77,25 @@ public async Task GetById(int id) [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetModelsInSeries(int id) + public Task GetModelsInSeries(int id) { - try - { - var models = await _repository.GetModelsInSeriesAsync(id); - if (models == null) + return ExecuteWithNotFoundAsync( + () => _repository.GetModelsInSeriesAsync(id), + models => { - return NotFound($"Model series with ID {id} not found"); - } - - var dtos = models.Select(m => new SeriesSimpleModelDto - { - Id = m.Id, - Name = m.Name, - Version = m.Version, - IsActive = m.IsActive - }); - - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting models in series {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while retrieving models"); - } + var dtos = models.Select(m => new SeriesSimpleModelDto + { + Id = m.Id, + Name = m.Name, + Version = m.Version, + IsActive = m.IsActive + }); + + return Ok(dtos); + }, + "Model series", + id, + "GetModelsInSeries"); } /// @@ -125,50 +108,44 @@ public async Task GetModelsInSeries(int id) [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task Create([FromBody] CreateModelSeriesDto dto) + public Task Create([FromBody] CreateModelSeriesDto dto) { - try - { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - // Check if series with same name and author already exists - var existing = await _repository.GetByNameAndAuthorAsync(dto.Name, dto.AuthorId); - if (existing != null) + return ExecuteAsync( + async () => { - return Conflict($"A model series with name '{dto.Name}' already exists for this author"); - } - - var series = new ModelSeries - { - AuthorId = dto.AuthorId, - Name = dto.Name, - Description = dto.Description, - TokenizerType = dto.TokenizerType, - Parameters = dto.Parameters ?? "{}" - }; + // Check if series with same name and author already exists + var existing = await _repository.GetByNameAndAuthorAsync(dto.Name, dto.AuthorId); + if (existing != null) + { + throw new InvalidOperationException($"A model series with name '{dto.Name}' already exists for this author"); + } - await _repository.CreateAsync(series); + var series = new ModelSeries + { + AuthorId = dto.AuthorId, + Name = dto.Name, + Description = dto.Description, + TokenizerType = dto.TokenizerType, + Parameters = dto.Parameters ?? "{}" + }; + + await _repository.CreateAsync(series); + + // Reload with author + var reloaded = await _repository.GetByIdWithAuthorAsync(series.Id); + if (reloaded == null) + { + throw new InvalidOperationException("Failed to reload created series"); + } - // Reload with author - series = await _repository.GetByIdWithAuthorAsync(series.Id); - if (series == null) - { - return StatusCode(StatusCodes.Status500InternalServerError, "Failed to reload created series"); - } - - return CreatedAtAction( + LogAdminAudit("Created", "ModelSeries", reloaded.Id, $"Name: {LoggingSanitizer.S(reloaded.Name)}"); + return reloaded; + }, + series => CreatedAtAction( nameof(GetById), new { id = series.Id }, - MapToDto(series)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating model series"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while creating the model series"); - } + series.ToDto()), + "Create"); } /// @@ -183,53 +160,46 @@ public async Task Create([FromBody] CreateModelSeriesDto dto) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task Update(int id, [FromBody] UpdateModelSeriesDto dto) + public Task Update(int id, [FromBody] UpdateModelSeriesDto dto) { - try + if (id != dto.Id) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - if (id != dto.Id) - { - return BadRequest("ID mismatch"); - } - - var series = await _repository.GetByIdAsync(id); - if (series == null) - { - return NotFound($"Model series with ID {id} not found"); - } + return Task.FromResult(BadRequest("ID mismatch")); + } - // Check for name conflicts if name is being changed - if (!string.IsNullOrEmpty(dto.Name) && dto.Name != series.Name) + return ExecuteAsync( + async () => { - var existing = await _repository.GetByNameAndAuthorAsync(dto.Name, series.AuthorId); - if (existing != null && existing.Id != id) + var series = await _repository.GetByIdAsync(id); + if (series == null) { - return Conflict($"A model series with name '{dto.Name}' already exists for this author"); + throw new KeyNotFoundException($"Model series with ID {id} not found"); } - series.Name = dto.Name; - } - if (dto.Description != null) - series.Description = dto.Description; - if (dto.TokenizerType.HasValue) - series.TokenizerType = dto.TokenizerType.Value; - if (dto.Parameters != null) - series.Parameters = dto.Parameters; - - await _repository.UpdateAsync(series); + // Check for name conflicts if name is being changed + if (!string.IsNullOrEmpty(dto.Name) && dto.Name != series.Name) + { + var existing = await _repository.GetByNameAndAuthorAsync(dto.Name, series.AuthorId); + if (existing != null && existing.Id != id) + { + throw new InvalidOperationException($"A model series with name '{dto.Name}' already exists for this author"); + } + series.Name = dto.Name; + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating model series with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while updating the model series"); - } + if (dto.Description != null) + series.Description = dto.Description; + if (dto.TokenizerType.HasValue) + series.TokenizerType = dto.TokenizerType.Value; + if (dto.Parameters != null) + series.Parameters = dto.Parameters; + + await _repository.UpdateAsync(series); + LogAdminAudit("Updated", "ModelSeries", id, $"Name: {LoggingSanitizer.S(series.Name)}"); + }, + NoContent(), + "Update", + new { Id = id }); } /// @@ -242,46 +212,31 @@ public async Task Update(int id, [FromBody] UpdateModelSeriesDto [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task Delete(int id) + public Task Delete(int id) { - try - { - var series = await _repository.GetByIdAsync(id); - if (series == null) - { - return NotFound($"Model series with ID {id} not found"); - } - - // Check if series has models - var models = await _repository.GetModelsInSeriesAsync(id); - if (models != null && models.Any()) + return ExecuteAsync( + async () => { - return Conflict($"Cannot delete model series with {models.Count()} associated models. Delete the models first."); - } + var series = await _repository.GetByIdAsync(id); + if (series == null) + { + throw new KeyNotFoundException($"Model series with ID {id} not found"); + } - await _repository.DeleteAsync(id); + // Check if series has models + var models = await _repository.GetModelsInSeriesAsync(id); + if (models != null && models.Any()) + { + throw new InvalidOperationException($"Cannot delete model series with {models.Count()} associated models. Delete the models first."); + } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting model series with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while deleting the model series"); - } + await _repository.DeleteAsync(id); + LogAdminAudit("Deleted", "ModelSeries", id, $"Name: {LoggingSanitizer.S(series.Name)}"); + }, + NoContent(), + "Delete", + new { Id = id }); } - private static ModelSeriesDto MapToDto(ModelSeries series) - { - return new ModelSeriesDto - { - Id = series.Id, - AuthorId = series.AuthorId, - AuthorName = series.Author?.Name, - Name = series.Name, - Description = series.Description, - TokenizerType = series.TokenizerType, - Parameters = series.Parameters - }; - } } } diff --git a/Services/ConduitLLM.Admin/Controllers/NotificationsController.cs b/Services/ConduitLLM.Admin/Controllers/NotificationsController.cs index 96f711cad..eb8c4dd3c 100644 --- a/Services/ConduitLLM.Admin/Controllers/NotificationsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/NotificationsController.cs @@ -1,5 +1,6 @@ using ConduitLLM.Admin.Interfaces; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,10 +13,9 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class NotificationsController : ControllerBase + public class NotificationsController : AdminControllerBase { private readonly IAdminNotificationService _notificationService; - private readonly ILogger _logger; /// /// Initializes a new instance of the NotificationsController @@ -25,9 +25,9 @@ public class NotificationsController : ControllerBase public NotificationsController( IAdminNotificationService notificationService, ILogger logger) + : base(logger) { _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -37,18 +37,12 @@ public NotificationsController( [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllNotifications() + public Task GetAllNotifications() { - try - { - var notifications = await _notificationService.GetAllNotificationsAsync(); - return Ok(notifications); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all notifications"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _notificationService.GetAllNotificationsAsync(), + Ok, + "GetAllNotifications"); } /// @@ -58,18 +52,12 @@ public async Task GetAllNotifications() [HttpGet("unread")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetUnreadNotifications() + public Task GetUnreadNotifications() { - try - { - var notifications = await _notificationService.GetUnreadNotificationsAsync(); - return Ok(notifications); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting unread notifications"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _notificationService.GetUnreadNotificationsAsync(), + Ok, + "GetUnreadNotifications"); } /// @@ -81,24 +69,14 @@ public async Task GetUnreadNotifications() [ProducesResponseType(typeof(NotificationDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetNotificationById(int id) + public Task GetNotificationById(int id) { - try - { - var notification = await _notificationService.GetNotificationByIdAsync(id); - - if (notification == null) - { - return NotFound("Notification not found"); - } - - return Ok(notification); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting notification with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _notificationService.GetNotificationByIdAsync(id), + Ok, + "Notification", + id, + "GetNotificationById"); } /// @@ -110,28 +88,17 @@ public async Task GetNotificationById(int id) [ProducesResponseType(typeof(NotificationDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateNotification([FromBody] CreateNotificationDto notification) + public Task CreateNotification([FromBody] CreateNotificationDto notification) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var createdNotification = await _notificationService.CreateNotificationAsync(notification); - return CreatedAtAction(nameof(GetNotificationById), new { id = createdNotification.Id }, createdNotification); - } - catch (ArgumentException ex) - { - _logger.LogWarning(ex, "Invalid argument when creating notification"); - return BadRequest(ex.Message); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating notification"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var result = await _notificationService.CreateNotificationAsync(notification); + LogAdminAudit("Created", "Notification", result.Id, $"Type: {result.Type}, Message: {LoggingSanitizer.S(result.Message)}"); + return result; + }, + createdNotification => CreatedAtAction(nameof(GetNotificationById), new { id = createdNotification.Id }, createdNotification), + "CreateNotification"); } /// @@ -145,35 +112,24 @@ public async Task CreateNotification([FromBody] CreateNotificatio [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateNotification(int id, [FromBody] UpdateNotificationDto notification) + public Task UpdateNotification(int id, [FromBody] UpdateNotificationDto notification) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - // Ensure ID in route matches ID in body if (id != notification.Id) { - return BadRequest("ID in route must match ID in body"); + return Task.FromResult(BadRequest("ID in route must match ID in body")); } - try - { - var success = await _notificationService.UpdateNotificationAsync(notification); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound("Notification not found"); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating notification with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!await _notificationService.UpdateNotificationAsync(notification)) + throw new KeyNotFoundException(); + LogAdminAudit("Updated", "Notification", id, notification.Message != null ? $"Message: {LoggingSanitizer.S(notification.Message)}" : null); + }, + NoContent(), + "UpdateNotification", + new { Id = id }); } /// @@ -185,24 +141,18 @@ public async Task UpdateNotification(int id, [FromBody] UpdateNot [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task MarkAsRead(int id) + public Task MarkAsRead(int id) { - try - { - var success = await _notificationService.MarkNotificationAsReadAsync(id); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound("Notification not found"); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error marking notification with ID {Id} as read", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!await _notificationService.MarkNotificationAsReadAsync(id)) + throw new KeyNotFoundException(); + LogAdminAudit("MarkedAsRead", "Notification", id, "IsRead: true"); + }, + NoContent(), + "MarkAsRead", + new { Id = id }); } /// @@ -212,18 +162,17 @@ public async Task MarkAsRead(int id) [HttpPost("mark-all-read")] [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task MarkAllAsRead() + public Task MarkAllAsRead() { - try - { - var count = await _notificationService.MarkAllNotificationsAsReadAsync(); - return Ok(count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error marking all notifications as read"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + async () => + { + var count = await _notificationService.MarkAllNotificationsAsReadAsync(); + LogAdminAudit("MarkedAllAsRead", "Notification", detail: $"Count: {count}"); + return count; + }, + result => Ok(result), + "MarkAllAsRead"); } /// @@ -235,24 +184,18 @@ public async Task MarkAllAsRead() [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteNotification(int id) + public Task DeleteNotification(int id) { - try - { - var success = await _notificationService.DeleteNotificationAsync(id); - - if (!success) + return ExecuteAsync( + async () => { - return NotFound("Notification not found"); - } - - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting notification with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!await _notificationService.DeleteNotificationAsync(id)) + throw new KeyNotFoundException(); + LogAdminAudit("Deleted", "Notification", id, $"Id: {id}"); + }, + NoContent(), + "DeleteNotification", + new { Id = id }); } } } diff --git a/Services/ConduitLLM.Admin/Controllers/PricingController.Audit.cs b/Services/ConduitLLM.Admin/Controllers/PricingController.Audit.cs new file mode 100644 index 000000000..9e4ba9b16 --- /dev/null +++ b/Services/ConduitLLM.Admin/Controllers/PricingController.Audit.cs @@ -0,0 +1,137 @@ +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Configuration.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Prometheus; + +namespace ConduitLLM.Admin.Controllers +{ + public partial class PricingController + { + /// + /// Query pricing audit events + /// + [HttpPost("audit/query")] + [ProducesResponseType(typeof(PricingAuditQueryResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public Task QueryPricingAuditEvents([FromBody] PricingAuditQueryRequest request) + { + if (ControllerErrorExtensions.ValidateDateRange(request.From, request.To) is { } dateError) + { + return Task.FromResult(dateError); + } + + if (request.PageSize > 1000) + { + return Task.FromResult(BadRequest("Page size cannot exceed 1000")); + } + + return ExecuteAsync( + async () => + { + using var timer = PricingOperationDuration.WithLabels("audit_query").NewTimer(); + + var (events, totalCount) = await _pricingAuditService.GetAuditEventsAsync( + request.From, + request.To, + request.VirtualKeyId, + request.ModelId, + request.PricingType, + request.PageNumber, + request.PageSize); + + LogAdminAudit("Queried", "PricingAudit", detail: $"From: {request.From:O}, To: {request.To:O}, Results: {totalCount}"); + return new PricingAuditQueryResponse + { + Events = events.Select(e => new PricingAuditEventDto + { + Id = e.Id, + Timestamp = e.Timestamp, + VirtualKeyId = e.VirtualKeyId, + ModelId = e.ModelId, + ModelCostId = e.ModelCostId, + PricingType = e.PricingType, + InputParameters = e.InputParameters, + MatchedRule = e.MatchedRule, + UsedDefaultRate = e.UsedDefaultRate, + AppliedRate = e.AppliedRate, + Quantity = e.Quantity, + CalculatedCost = e.CalculatedCost, + RequestId = e.RequestId + }).ToList(), + TotalCount = totalCount, + PageNumber = request.PageNumber, + PageSize = request.PageSize + }; + }, + Ok, + "QueryPricingAuditEvents"); + } + + /// + /// Get pricing audit summary + /// + [HttpGet("audit/summary")] + [ProducesResponseType(typeof(PricingAuditSummary), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public Task GetPricingAuditSummary( + [FromQuery] DateTime from, + [FromQuery] DateTime to, + [FromQuery] int? virtualKeyId = null) + { + if (ControllerErrorExtensions.ValidateDateRange(from, to) is { } dateError) + { + return Task.FromResult(dateError); + } + + return ExecuteAsync( + async () => + { + using var timer = PricingOperationDuration.WithLabels("audit_summary").NewTimer(); + + return await _pricingAuditService.GetSummaryAsync(from, to, virtualKeyId); + }, + Ok, + "GetPricingAuditSummary"); + } + + /// + /// Get pricing audit events by request ID + /// + [HttpGet("audit/request/{requestId}")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task GetPricingAuditByRequestId(string requestId) + { + return ExecuteAsync( + async () => + { + var events = await _pricingAuditService.GetByRequestIdAsync(requestId); + + if (!events.Any()) + { + throw new KeyNotFoundException($"No pricing audit events found for request {requestId}"); + } + + return events.Select(e => new PricingAuditEventDto + { + Id = e.Id, + Timestamp = e.Timestamp, + VirtualKeyId = e.VirtualKeyId, + ModelId = e.ModelId, + ModelCostId = e.ModelCostId, + PricingType = e.PricingType, + InputParameters = e.InputParameters, + MatchedRule = e.MatchedRule, + UsedDefaultRate = e.UsedDefaultRate, + AppliedRate = e.AppliedRate, + Quantity = e.Quantity, + CalculatedCost = e.CalculatedCost, + RequestId = e.RequestId + }); + }, + Ok, + "GetPricingAuditByRequestId", + new { RequestId = requestId }); + } + } +} diff --git a/Services/ConduitLLM.Admin/Controllers/PricingController.Dtos.cs b/Services/ConduitLLM.Admin/Controllers/PricingController.Dtos.cs new file mode 100644 index 000000000..0afb4e25b --- /dev/null +++ b/Services/ConduitLLM.Admin/Controllers/PricingController.Dtos.cs @@ -0,0 +1,279 @@ +namespace ConduitLLM.Admin.Controllers +{ + /// + /// Request to validate pricing configuration + /// + public class PricingValidationRequest + { + /// + /// The pricing configuration JSON to validate + /// + public string PricingConfiguration { get; set; } = string.Empty; + } + + /// + /// Response from pricing validation + /// + public class PricingValidationResponse + { + /// + /// Whether the configuration is valid + /// + public bool IsValid { get; set; } + + /// + /// Validation errors if any + /// + public string[] Errors { get; set; } = Array.Empty(); + + /// + /// Validation warnings if any + /// + public string[] Warnings { get; set; } = Array.Empty(); + } + + /// + /// Request to simulate pricing calculation + /// + public class PricingSimulationRequest + { + /// + /// The pricing configuration JSON + /// + public string PricingConfiguration { get; set; } = string.Empty; + + /// + /// Parameters for the simulation + /// + public Dictionary? Parameters { get; set; } + + /// + /// Video duration in seconds (for per_second pricing) + /// + public double? VideoDurationSeconds { get; set; } + + /// + /// Video resolution (e.g., "1080p") + /// + public string? VideoResolution { get; set; } + + /// + /// Image count (for per_unit pricing) + /// + public int? ImageCount { get; set; } + + /// + /// Image resolution (e.g., "1024x1024") + /// + public string? ImageResolution { get; set; } + + /// + /// Image quality (e.g., "hd", "standard") + /// + public string? ImageQuality { get; set; } + } + + /// + /// Response from pricing simulation + /// + public class PricingSimulationResponse + { + /// + /// The calculated cost + /// + public decimal CalculatedCost { get; set; } + + /// + /// The rate that was applied + /// + public decimal AppliedRate { get; set; } + + /// + /// The quantity used in calculation + /// + public decimal Quantity { get; set; } + + /// + /// Information about the matched rule, if any + /// + public MatchedRuleInfo? MatchedRule { get; set; } + + /// + /// Whether the default rate was used + /// + public bool UsedDefaultRate { get; set; } + + /// + /// Warning message if any + /// + public string? WarningMessage { get; set; } + } + + /// + /// Information about a matched pricing rule + /// + public class MatchedRuleInfo + { + /// + /// Rule description + /// + public string? Description { get; set; } + + /// + /// Rule priority + /// + public int Priority { get; set; } + + /// + /// Rule rate + /// + public decimal Rate { get; set; } + + /// + /// Summary of conditions + /// + public string[]? ConditionsSummary { get; set; } + } + + /// + /// Information about a pricing type + /// + public class PricingTypeInfo + { + /// + /// The pricing type identifier + /// + public string Type { get; set; } = string.Empty; + + /// + /// Description of the pricing type + /// + public string Description { get; set; } = string.Empty; + + /// + /// Example calculation + /// + public string Example { get; set; } = string.Empty; + } + + /// + /// Information about a condition operator + /// + public class OperatorInfo + { + /// + /// The operator identifier + /// + public string Operator { get; set; } = string.Empty; + + /// + /// Description of the operator + /// + public string Description { get; set; } = string.Empty; + + /// + /// Example usage + /// + public string Example { get; set; } = string.Empty; + } + + /// + /// Request to query pricing audit events + /// + public class PricingAuditQueryRequest + { + /// + /// Start date + /// + public DateTime From { get; set; } + + /// + /// End date + /// + public DateTime To { get; set; } + + /// + /// Optional virtual key ID filter + /// + public int? VirtualKeyId { get; set; } + + /// + /// Optional model ID filter + /// + public string? ModelId { get; set; } + + /// + /// Optional pricing type filter + /// + public string? PricingType { get; set; } + + /// + /// Page number (1-based) + /// + public int PageNumber { get; set; } = 1; + + /// + /// Page size + /// + public int PageSize { get; set; } = 50; + } + + /// + /// Response from pricing audit query + /// + public class PricingAuditQueryResponse + { + /// + /// The audit events + /// + public List Events { get; set; } = new(); + + /// + /// Total count of matching events + /// + public int TotalCount { get; set; } + + /// + /// Current page number + /// + public int PageNumber { get; set; } + + /// + /// Page size + /// + public int PageSize { get; set; } + } + + /// + /// Pricing audit event DTO + /// + public class PricingAuditEventDto + { + /// Unique identifier for the audit event. + public long Id { get; set; } + /// When the pricing event occurred. + public DateTime Timestamp { get; set; } + /// The virtual key ID associated with this event. + public int VirtualKeyId { get; set; } + /// The model identifier used for pricing. + public string ModelId { get; set; } = string.Empty; + /// The model cost configuration ID that was applied. + public int ModelCostId { get; set; } + /// The type of pricing applied (e.g., token, image, audio). + public string PricingType { get; set; } = string.Empty; + /// JSON representation of input parameters used for pricing calculation. + public string InputParameters { get; set; } = string.Empty; + /// The pricing rule that matched, if any. + public string? MatchedRule { get; set; } + /// Whether the default rate was used instead of a specific rule. + public bool UsedDefaultRate { get; set; } + /// The rate that was applied for pricing. + public decimal AppliedRate { get; set; } + /// The quantity (tokens, images, seconds, etc.) being priced. + public decimal Quantity { get; set; } + /// The final calculated cost. + public decimal CalculatedCost { get; set; } + /// The request ID for correlation, if available. + public string? RequestId { get; set; } + } +} diff --git a/Services/ConduitLLM.Admin/Controllers/PricingController.Simulation.cs b/Services/ConduitLLM.Admin/Controllers/PricingController.Simulation.cs new file mode 100644 index 000000000..9a5bb4f4f --- /dev/null +++ b/Services/ConduitLLM.Admin/Controllers/PricingController.Simulation.cs @@ -0,0 +1,115 @@ +using ConduitLLM.Core.Models.Pricing; +using Microsoft.AspNetCore.Mvc; +using Prometheus; + +namespace ConduitLLM.Admin.Controllers +{ + public partial class PricingController + { + /// + /// Validate pricing configuration JSON + /// + /// The pricing configuration to validate + /// Validation result with any errors + [HttpPost("validate")] + [ProducesResponseType(typeof(PricingValidationResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public Task ValidatePricingConfiguration([FromBody] PricingValidationRequest request) + { + return ExecuteAsync( + () => + { + using var timer = PricingOperationDuration.WithLabels("validate").NewTimer(); + + if (!TryDeserializePricingConfig(request.PricingConfiguration, out var config, out var errorMessage)) + { + PricingValidations.WithLabels(errorMessage!.StartsWith("Invalid JSON") ? "invalid_json" : "null_config").Inc(); + return Task.FromResult(new PricingValidationResponse + { + IsValid = false, + Errors = new[] { errorMessage! } + }); + } + + var result = _pricingValidator.Validate(config!); + + PricingValidations.WithLabels(result.IsValid ? "valid" : "invalid").Inc(); + LogAdminAudit("Validated", "PricingConfiguration", detail: $"IsValid: {result.IsValid}, Errors: {result.Errors.Count}"); + return Task.FromResult(new PricingValidationResponse + { + IsValid = result.IsValid, + Errors = result.Errors.Select(e => $"[{e.Field}] {e.Message}" + (e.RuleIndex.HasValue ? $" (rule {e.RuleIndex})" : "")).ToArray(), + Warnings = result.Warnings.ToArray() + }); + }, + Ok, + "ValidatePricingConfiguration"); + } + + /// + /// Simulate pricing calculation with test parameters + /// + /// The simulation request with configuration and test parameters + /// Calculated pricing result + [HttpPost("simulate")] + [ProducesResponseType(typeof(PricingSimulationResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public Task SimulatePricing([FromBody] PricingSimulationRequest request) + { + return ExecuteAsync( + () => + { + using var timer = PricingOperationDuration.WithLabels("simulate").NewTimer(); + + // Parse pricing configuration + if (!TryDeserializePricingConfig(request.PricingConfiguration, out var config, out var errorMessage)) + { + PricingSimulations.WithLabels(errorMessage!.StartsWith("Invalid JSON") ? "invalid_json" : "null_config").Inc(); + throw new ArgumentException($"Invalid pricing configuration JSON: {errorMessage}"); + } + + // Validate configuration first + var validationResult = _pricingValidator.Validate(config!); + if (!validationResult.IsValid) + { + PricingSimulations.WithLabels("invalid_config").Inc(); + throw new ArgumentException("Pricing configuration is invalid"); + } + + // Build usage object for simulation + var usage = new ConduitLLM.Core.Models.Usage + { + VideoDurationSeconds = request.VideoDurationSeconds, + VideoResolution = request.VideoResolution, + ImageCount = request.ImageCount, + ImageResolution = request.ImageResolution, + ImageQuality = request.ImageQuality, + PricingParameters = request.Parameters ?? new Dictionary() + }; + + // Evaluate the pricing rules + var result = _pricingEvaluator.Evaluate(config!, request.Parameters ?? new Dictionary(), usage); + + PricingSimulations.WithLabels("success").Inc(); + LogAdminAudit("Simulated", "PricingCalculation", detail: $"Cost: {result.Cost}, UsedDefault: {result.UsedDefaultRate}"); + return Task.FromResult(new PricingSimulationResponse + { + CalculatedCost = result.Cost, + AppliedRate = result.Rate, + Quantity = result.Quantity, + MatchedRule = result.MatchedRule != null ? new MatchedRuleInfo + { + Description = result.MatchedRule.Description, + Priority = result.MatchedRule.Priority, + Rate = result.MatchedRule.Rate, + ConditionsSummary = result.MatchedRule.Conditions?.Select(c => $"{c.Key} = {c.Value}").ToArray() + } : null, + UsedDefaultRate = result.UsedDefaultRate, + WarningMessage = result.UsedDefaultRate ? "No matching rule found, default rate was used" : null + }); + }, + Ok, + "SimulatePricing"); + } + } +} diff --git a/Services/ConduitLLM.Admin/Controllers/PricingController.cs b/Services/ConduitLLM.Admin/Controllers/PricingController.cs index 1f8bfba77..af6c637fa 100644 --- a/Services/ConduitLLM.Admin/Controllers/PricingController.cs +++ b/Services/ConduitLLM.Admin/Controllers/PricingController.cs @@ -14,12 +14,11 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public class PricingController : ControllerBase + public partial class PricingController : AdminControllerBase { private readonly IPricingRulesValidator _pricingValidator; private readonly IPricingRulesEvaluator _pricingEvaluator; private readonly IPricingAuditService _pricingAuditService; - private readonly ILogger _logger; // Metrics for pricing API operations private static readonly Counter PricingValidations = Prometheus.Metrics @@ -44,6 +43,11 @@ public class PricingController : ControllerBase Buckets = Histogram.ExponentialBuckets(0.001, 2, 10) // 1ms to ~1s }); + private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + /// /// Initializes a new instance of the PricingController /// @@ -52,157 +56,44 @@ public PricingController( IPricingRulesEvaluator pricingEvaluator, IPricingAuditService pricingAuditService, ILogger logger) + : base(logger) { _pricingValidator = pricingValidator ?? throw new ArgumentNullException(nameof(pricingValidator)); _pricingEvaluator = pricingEvaluator ?? throw new ArgumentNullException(nameof(pricingEvaluator)); _pricingAuditService = pricingAuditService ?? throw new ArgumentNullException(nameof(pricingAuditService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - /// Validate pricing configuration JSON + /// Attempts to deserialize a JSON string into a pricing configuration object. + /// Returns true if successful, false if the JSON is invalid or null. /// - /// The pricing configuration to validate - /// Validation result with any errors - [HttpPost("validate")] - [ProducesResponseType(typeof(PricingValidationResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public IActionResult ValidatePricingConfiguration([FromBody] PricingValidationRequest request) + /// The type to deserialize to + /// The JSON string to deserialize + /// The deserialized configuration, or null on failure + /// The error message if deserialization fails, or null on success + /// True if deserialization succeeded and result is non-null + private static bool TryDeserializePricingConfig(string json, out T? config, out string? errorMessage) where T : class { + config = null; + errorMessage = null; + try { - using var timer = PricingOperationDuration.WithLabels("validate").NewTimer(); - - PricingRulesConfig? config = null; - try - { - config = JsonSerializer.Deserialize(request.PricingConfiguration, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - } - catch (JsonException ex) - { - PricingValidations.WithLabels("invalid_json").Inc(); - return Ok(new PricingValidationResponse - { - IsValid = false, - Errors = new[] { $"Invalid JSON format: {ex.Message}" } - }); - } - - if (config == null) - { - PricingValidations.WithLabels("null_config").Inc(); - return Ok(new PricingValidationResponse - { - IsValid = false, - Errors = new[] { "Configuration could not be parsed" } - }); - } - - var result = _pricingValidator.Validate(config); - - PricingValidations.WithLabels(result.IsValid ? "valid" : "invalid").Inc(); - return Ok(new PricingValidationResponse - { - IsValid = result.IsValid, - Errors = result.Errors.Select(e => $"[{e.Field}] {e.Message}" + (e.RuleIndex.HasValue ? $" (rule {e.RuleIndex})" : "")).ToArray(), - Warnings = result.Warnings.ToArray() - }); + config = JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); } - catch (Exception ex) + catch (JsonException ex) { - PricingValidations.WithLabels("error").Inc(); - _logger.LogError(ex, "Error validating pricing configuration"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while validating pricing configuration"); + errorMessage = $"Invalid JSON format: {ex.Message}"; + return false; } - } - - /// - /// Simulate pricing calculation with test parameters - /// - /// The simulation request with configuration and test parameters - /// Calculated pricing result - [HttpPost("simulate")] - [ProducesResponseType(typeof(PricingSimulationResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public IActionResult SimulatePricing([FromBody] PricingSimulationRequest request) - { - try - { - using var timer = PricingOperationDuration.WithLabels("simulate").NewTimer(); - - // Parse pricing configuration - PricingRulesConfig? config = null; - try - { - config = JsonSerializer.Deserialize(request.PricingConfiguration, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - } - catch (JsonException ex) - { - PricingSimulations.WithLabels("invalid_json").Inc(); - return BadRequest($"Invalid pricing configuration JSON: {ex.Message}"); - } - - if (config == null) - { - PricingSimulations.WithLabels("null_config").Inc(); - return BadRequest("Configuration could not be parsed"); - } - - // Validate configuration first - var validationResult = _pricingValidator.Validate(config); - if (!validationResult.IsValid) - { - PricingSimulations.WithLabels("invalid_config").Inc(); - return BadRequest(new - { - Message = "Pricing configuration is invalid", - Errors = validationResult.Errors - }); - } - - // Build usage object for simulation - var usage = new ConduitLLM.Core.Models.Usage - { - VideoDurationSeconds = request.VideoDurationSeconds, - VideoResolution = request.VideoResolution, - ImageCount = request.ImageCount, - ImageResolution = request.ImageResolution, - ImageQuality = request.ImageQuality, - PricingParameters = request.Parameters ?? new Dictionary() - }; - // Evaluate the pricing rules - var result = _pricingEvaluator.Evaluate(config, request.Parameters ?? new Dictionary(), usage); - - PricingSimulations.WithLabels("success").Inc(); - return Ok(new PricingSimulationResponse - { - CalculatedCost = result.Cost, - AppliedRate = result.Rate, - Quantity = result.Quantity, - MatchedRule = result.MatchedRule != null ? new MatchedRuleInfo - { - Description = result.MatchedRule.Description, - Priority = result.MatchedRule.Priority, - Rate = result.MatchedRule.Rate, - ConditionsSummary = result.MatchedRule.Conditions?.Select(c => $"{c.Key} = {c.Value}").ToArray() - } : null, - UsedDefaultRate = result.UsedDefaultRate, - WarningMessage = result.UsedDefaultRate ? "No matching rule found, default rate was used" : null - }); - } - catch (Exception ex) + if (config == null) { - PricingSimulations.WithLabels("error").Inc(); - _logger.LogError(ex, "Error simulating pricing"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while simulating pricing"); + errorMessage = "Configuration could not be parsed"; + return false; } + + return true; } /// @@ -359,420 +250,5 @@ public IActionResult GetPricingTemplate([FromQuery] string? pricingType = "per_s return Ok(template); } - - /// - /// Query pricing audit events - /// - [HttpPost("audit/query")] - [ProducesResponseType(typeof(PricingAuditQueryResponse), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task QueryPricingAuditEvents([FromBody] PricingAuditQueryRequest request) - { - if (request.From > request.To) - { - return BadRequest("From date must be before or equal to To date"); - } - - if (request.PageSize > 1000) - { - return BadRequest("Page size cannot exceed 1000"); - } - - try - { - using var timer = PricingOperationDuration.WithLabels("audit_query").NewTimer(); - - var (events, totalCount) = await _pricingAuditService.GetAuditEventsAsync( - request.From, - request.To, - request.VirtualKeyId, - request.ModelId, - request.PricingType, - request.PageNumber, - request.PageSize); - - var response = new PricingAuditQueryResponse - { - Events = events.Select(e => new PricingAuditEventDto - { - Id = e.Id, - Timestamp = e.Timestamp, - VirtualKeyId = e.VirtualKeyId, - ModelId = e.ModelId, - ModelCostId = e.ModelCostId, - PricingType = e.PricingType, - InputParameters = e.InputParameters, - MatchedRule = e.MatchedRule, - UsedDefaultRate = e.UsedDefaultRate, - AppliedRate = e.AppliedRate, - Quantity = e.Quantity, - CalculatedCost = e.CalculatedCost, - RequestId = e.RequestId - }).ToList(), - TotalCount = totalCount, - PageNumber = request.PageNumber, - PageSize = request.PageSize - }; - - return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error querying pricing audit events"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while querying pricing audit events"); - } - } - - /// - /// Get pricing audit summary - /// - [HttpGet("audit/summary")] - [ProducesResponseType(typeof(PricingAuditSummary), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task GetPricingAuditSummary( - [FromQuery] DateTime from, - [FromQuery] DateTime to, - [FromQuery] int? virtualKeyId = null) - { - if (from > to) - { - return BadRequest("From date must be before or equal to To date"); - } - - try - { - using var timer = PricingOperationDuration.WithLabels("audit_summary").NewTimer(); - - var summary = await _pricingAuditService.GetSummaryAsync(from, to, virtualKeyId); - return Ok(summary); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting pricing audit summary"); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while getting pricing audit summary"); - } - } - - /// - /// Get pricing audit events by request ID - /// - [HttpGet("audit/request/{requestId}")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetPricingAuditByRequestId(string requestId) - { - try - { - var events = await _pricingAuditService.GetByRequestIdAsync(requestId); - - if (!events.Any()) - { - return NotFound($"No pricing audit events found for request {requestId}"); - } - - return Ok(events.Select(e => new PricingAuditEventDto - { - Id = e.Id, - Timestamp = e.Timestamp, - VirtualKeyId = e.VirtualKeyId, - ModelId = e.ModelId, - ModelCostId = e.ModelCostId, - PricingType = e.PricingType, - InputParameters = e.InputParameters, - MatchedRule = e.MatchedRule, - UsedDefaultRate = e.UsedDefaultRate, - AppliedRate = e.AppliedRate, - Quantity = e.Quantity, - CalculatedCost = e.CalculatedCost, - RequestId = e.RequestId - })); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting pricing audit events for request {RequestId}", requestId); - return StatusCode(StatusCodes.Status500InternalServerError, "An error occurred while getting pricing audit events"); - } - } } - - #region DTOs - - /// - /// Request to validate pricing configuration - /// - public class PricingValidationRequest - { - /// - /// The pricing configuration JSON to validate - /// - public string PricingConfiguration { get; set; } = string.Empty; - } - - /// - /// Response from pricing validation - /// - public class PricingValidationResponse - { - /// - /// Whether the configuration is valid - /// - public bool IsValid { get; set; } - - /// - /// Validation errors if any - /// - public string[] Errors { get; set; } = Array.Empty(); - - /// - /// Validation warnings if any - /// - public string[] Warnings { get; set; } = Array.Empty(); - } - - /// - /// Request to simulate pricing calculation - /// - public class PricingSimulationRequest - { - /// - /// The pricing configuration JSON - /// - public string PricingConfiguration { get; set; } = string.Empty; - - /// - /// Parameters for the simulation - /// - public Dictionary? Parameters { get; set; } - - /// - /// Video duration in seconds (for per_second pricing) - /// - public double? VideoDurationSeconds { get; set; } - - /// - /// Video resolution (e.g., "1080p") - /// - public string? VideoResolution { get; set; } - - /// - /// Image count (for per_unit pricing) - /// - public int? ImageCount { get; set; } - - /// - /// Image resolution (e.g., "1024x1024") - /// - public string? ImageResolution { get; set; } - - /// - /// Image quality (e.g., "hd", "standard") - /// - public string? ImageQuality { get; set; } - } - - /// - /// Response from pricing simulation - /// - public class PricingSimulationResponse - { - /// - /// The calculated cost - /// - public decimal CalculatedCost { get; set; } - - /// - /// The rate that was applied - /// - public decimal AppliedRate { get; set; } - - /// - /// The quantity used in calculation - /// - public decimal Quantity { get; set; } - - /// - /// Information about the matched rule, if any - /// - public MatchedRuleInfo? MatchedRule { get; set; } - - /// - /// Whether the default rate was used - /// - public bool UsedDefaultRate { get; set; } - - /// - /// Warning message if any - /// - public string? WarningMessage { get; set; } - } - - /// - /// Information about a matched pricing rule - /// - public class MatchedRuleInfo - { - /// - /// Rule description - /// - public string? Description { get; set; } - - /// - /// Rule priority - /// - public int Priority { get; set; } - - /// - /// Rule rate - /// - public decimal Rate { get; set; } - - /// - /// Summary of conditions - /// - public string[]? ConditionsSummary { get; set; } - } - - /// - /// Information about a pricing type - /// - public class PricingTypeInfo - { - /// - /// The pricing type identifier - /// - public string Type { get; set; } = string.Empty; - - /// - /// Description of the pricing type - /// - public string Description { get; set; } = string.Empty; - - /// - /// Example calculation - /// - public string Example { get; set; } = string.Empty; - } - - /// - /// Information about a condition operator - /// - public class OperatorInfo - { - /// - /// The operator identifier - /// - public string Operator { get; set; } = string.Empty; - - /// - /// Description of the operator - /// - public string Description { get; set; } = string.Empty; - - /// - /// Example usage - /// - public string Example { get; set; } = string.Empty; - } - - /// - /// Request to query pricing audit events - /// - public class PricingAuditQueryRequest - { - /// - /// Start date - /// - public DateTime From { get; set; } - - /// - /// End date - /// - public DateTime To { get; set; } - - /// - /// Optional virtual key ID filter - /// - public int? VirtualKeyId { get; set; } - - /// - /// Optional model ID filter - /// - public string? ModelId { get; set; } - - /// - /// Optional pricing type filter - /// - public string? PricingType { get; set; } - - /// - /// Page number (1-based) - /// - public int PageNumber { get; set; } = 1; - - /// - /// Page size - /// - public int PageSize { get; set; } = 50; - } - - /// - /// Response from pricing audit query - /// - public class PricingAuditQueryResponse - { - /// - /// The audit events - /// - public List Events { get; set; } = new(); - - /// - /// Total count of matching events - /// - public int TotalCount { get; set; } - - /// - /// Current page number - /// - public int PageNumber { get; set; } - - /// - /// Page size - /// - public int PageSize { get; set; } - } - - /// - /// Pricing audit event DTO - /// - public class PricingAuditEventDto - { - /// Unique identifier for the audit event. - public long Id { get; set; } - /// When the pricing event occurred. - public DateTime Timestamp { get; set; } - /// The virtual key ID associated with this event. - public int VirtualKeyId { get; set; } - /// The model identifier used for pricing. - public string ModelId { get; set; } = string.Empty; - /// The model cost configuration ID that was applied. - public int ModelCostId { get; set; } - /// The type of pricing applied (e.g., token, image, audio). - public string PricingType { get; set; } = string.Empty; - /// JSON representation of input parameters used for pricing calculation. - public string InputParameters { get; set; } = string.Empty; - /// The pricing rule that matched, if any. - public string? MatchedRule { get; set; } - /// Whether the default rate was used instead of a specific rule. - public bool UsedDefaultRate { get; set; } - /// The rate that was applied for pricing. - public decimal AppliedRate { get; set; } - /// The quantity (tokens, images, seconds, etc.) being priced. - public decimal Quantity { get; set; } - /// The final calculated cost. - public decimal CalculatedCost { get; set; } - /// The request ID for correlation, if available. - public string? RequestId { get; set; } - } - - #endregion } diff --git a/Services/ConduitLLM.Admin/Controllers/PromptCachingController.cs b/Services/ConduitLLM.Admin/Controllers/PromptCachingController.cs new file mode 100644 index 000000000..4c4da3ed1 --- /dev/null +++ b/Services/ConduitLLM.Admin/Controllers/PromptCachingController.cs @@ -0,0 +1,155 @@ +using System.Text.Json; + +using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.DTOs.PromptCaching; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Models; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ConduitLLM.Admin.Controllers; + +/// +/// Controller for managing prompt caching configuration. +/// Provides a typed API over the PromptCaching.Config global setting. +/// +[ApiController] +[Route("api/prompt-caching")] +[Authorize(Policy = "MasterKeyPolicy")] +public class PromptCachingController : AdminControllerBase +{ + private const string SettingKey = "PromptCaching.Config"; + + private readonly IAdminGlobalSettingService _globalSettingService; + private readonly IGlobalSettingsCacheService _cacheService; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + /// + /// Initializes a new instance of the class. + /// + /// The global setting service for CRUD operations. + /// The cache service for reading and invalidating settings. + /// The logger instance. + public PromptCachingController( + IAdminGlobalSettingService globalSettingService, + IGlobalSettingsCacheService cacheService, + ILogger logger) + : base(logger) + { + _globalSettingService = globalSettingService ?? throw new ArgumentNullException(nameof(globalSettingService)); + _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + } + + /// + /// Gets the current prompt caching configuration. + /// + /// The current prompt caching configuration, or defaults if not set. + [HttpGet("config")] + [ProducesResponseType(typeof(PromptCachingConfigDto), StatusCodes.Status200OK)] + public Task GetConfig() + { + return ExecuteAsync( + async () => + { + var json = await _cacheService.GetSettingValueAsync(SettingKey); + if (json == null) + { + return new PromptCachingConfigDto + { + AutoInjectEnabled = false, + InjectionPoints = new List() + }; + } + + var config = JsonSerializer.Deserialize(json, JsonOptions); + if (config == null) + { + return new PromptCachingConfigDto + { + AutoInjectEnabled = false, + InjectionPoints = new List() + }; + } + + return new PromptCachingConfigDto + { + AutoInjectEnabled = config.AutoInjectEnabled, + InjectionPoints = config.InjectionPoints.Select(p => new CacheInjectionPointDto + { + Role = p.Role, + Index = p.Index + }).ToList() + }; + }, + Ok, + "GetPromptCachingConfig"); + } + + /// + /// Updates the prompt caching configuration. + /// + /// The new prompt caching configuration. + /// The updated configuration. + [HttpPut("config")] + [ProducesResponseType(typeof(PromptCachingConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponseDto), StatusCodes.Status400BadRequest)] + public Task UpdateConfig([FromBody] UpdatePromptCachingConfigDto dto) + { + return ExecuteAsync( + async () => + { + // Map DTO to domain model + var config = new PromptCachingConfig + { + AutoInjectEnabled = dto.AutoInjectEnabled, + InjectionPoints = dto.InjectionPoints.Select(p => new CacheInjectionPoint + { + Role = p.Role, + Index = p.Index + }).ToList() + }; + + var json = JsonSerializer.Serialize(config, JsonOptions); + + // Upsert: try update first, create if not found + var existing = await _globalSettingService.GetSettingByKeyAsync(SettingKey); + if (existing != null) + { + await _globalSettingService.UpdateSettingByKeyAsync(new UpdateGlobalSettingByKeyDto + { + Key = SettingKey, + Value = json, + Description = "Prompt caching auto-injection configuration" + }); + } + else + { + await _globalSettingService.CreateSettingAsync(new CreateGlobalSettingDto + { + Key = SettingKey, + Value = json, + Description = "Prompt caching auto-injection configuration" + }); + } + + // Invalidate cache so changes take effect immediately + await _cacheService.InvalidateSettingAsync(SettingKey); + + LogAdminAudit("Updated", "PromptCachingConfig", detail: $"AutoInject={dto.AutoInjectEnabled}, Points={dto.InjectionPoints.Count}"); + + return new PromptCachingConfigDto + { + AutoInjectEnabled = dto.AutoInjectEnabled, + InjectionPoints = dto.InjectionPoints + }; + }, + Ok, + "UpdatePromptCachingConfig"); + } +} diff --git a/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Keys.cs b/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Keys.cs index 9883cda55..ca22a6d78 100644 --- a/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Keys.cs +++ b/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Keys.cs @@ -1,5 +1,9 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Admin.Services; using Microsoft.AspNetCore.Mvc; namespace ConduitLLM.Admin.Controllers @@ -15,32 +19,31 @@ public partial class ProviderCredentialsController [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetProviderKeyCredentials(int providerId) + public Task GetProviderKeyCredentials(int providerId) { - try - { - var keys = await _keyRepository.GetByProviderIdAsync(providerId); - var result = keys.Select(k => new + return ExecuteAsync( + async () => { - k.Id, - k.ProviderId, - k.KeyName, - k.IsPrimary, - k.IsEnabled, - k.ProviderAccountGroup, - ApiKey = k.ApiKey != null ? "***" + k.ApiKey.Substring(Math.Max(0, k.ApiKey.Length - 4)) : "***", // Mask API key - k.Organization, - k.BaseUrl, - k.CreatedAt, - k.UpdatedAt - }); - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting key credentials for provider {ProviderId}", providerId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var keys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _keyRepository.GetByProviderIdPaginatedAsync, providerId); + return keys.Select(k => new + { + k.Id, + k.ProviderId, + k.KeyName, + k.IsPrimary, + k.IsEnabled, + k.ProviderAccountGroup, + ApiKey = k.ApiKey != null ? "***" + k.ApiKey.Substring(Math.Max(0, k.ApiKey.Length - 4)) : "***", // Mask API key + k.Organization, + k.BaseUrl, + k.CreatedAt, + k.UpdatedAt + }); + }, + result => Ok(result), + "GetProviderKeyCredentials", + new { ProviderId = providerId }); } /// @@ -53,38 +56,36 @@ public async Task GetProviderKeyCredentials(int providerId) [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetProviderKeyCredential(int providerId, int keyId) + public Task GetProviderKeyCredential(int providerId, int keyId) { - try - { - var key = await _keyRepository.GetByIdAsync(keyId); - - if (key == null || key.ProviderId != providerId) + return ExecuteAsync( + async () => { - _logger.LogWarning("Key credential not found {KeyId} for provider {ProviderId}", keyId, providerId); - return NotFound(new ErrorResponseDto("Key credential not found")); - } + var key = await _keyRepository.GetByIdAsync(keyId); - return Ok(new - { - key.Id, - key.ProviderId, - key.KeyName, - key.IsPrimary, - key.IsEnabled, - key.ProviderAccountGroup, - ApiKey = key.ApiKey != null ? "***" + key.ApiKey.Substring(Math.Max(0, key.ApiKey.Length - 4)) : "***", // Mask API key - key.Organization, - key.BaseUrl, - key.CreatedAt, - key.UpdatedAt - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting key credential {KeyId} for provider {ProviderId}", keyId, providerId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (key == null || key.ProviderId != providerId) + { + Logger.LogWarning("Key credential not found {KeyId} for provider {ProviderId}", keyId, providerId); + return this.NotFoundEntity("Key credential", keyId); + } + + return Ok(new + { + key.Id, + key.ProviderId, + key.KeyName, + key.IsPrimary, + key.IsEnabled, + key.ProviderAccountGroup, + ApiKey = key.ApiKey != null ? "***" + key.ApiKey.Substring(Math.Max(0, key.ApiKey.Length - 4)) : "***", // Mask API key + key.Organization, + key.BaseUrl, + key.CreatedAt, + key.UpdatedAt + }); + }, + "GetProviderKeyCredential", + new { ProviderId = providerId, KeyId = keyId }); } /// @@ -98,78 +99,68 @@ public async Task GetProviderKeyCredential(int providerId, int ke [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateProviderKeyCredential(int providerId, [FromBody] CreateKeyRequest request) + public Task CreateProviderKeyCredential(int providerId, [FromBody] CreateKeyRequest request) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - // Verify provider exists - var provider = await _providerRepository.GetByIdAsync(providerId); - if (provider == null) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Provider not found")); - } - - var keyCredential = new ProviderKeyCredential - { - ProviderId = providerId, - ApiKey = request.ApiKey, - KeyName = request.KeyName, - Organization = request.Organization, - BaseUrl = request.BaseUrl, - IsPrimary = request.IsPrimary, - IsEnabled = request.IsEnabled, - ProviderAccountGroup = (short)(request.ProviderAccountGroup ?? 0), - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; + // Verify provider exists + var provider = await _providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + return this.NotFoundEntity("Provider", providerId); + } - var createdKey = await _keyRepository.CreateAsync(keyCredential); + var keyCredential = new ProviderKeyCredential + { + ProviderId = providerId, + ApiKey = request.ApiKey, + KeyName = request.KeyName, + Organization = request.Organization, + BaseUrl = request.BaseUrl, + IsPrimary = request.IsPrimary, + IsEnabled = request.IsEnabled, + ProviderAccountGroup = (short)(request.ProviderAccountGroup ?? 0), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; - // Publish key created event - PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialCreated - { - KeyId = createdKey.Id, - ProviderId = providerId, - IsPrimary = keyCredential.IsPrimary, - IsEnabled = keyCredential.IsEnabled, - CorrelationId = Guid.NewGuid() - }, "create provider key", new { ProviderId = providerId, KeyId = createdKey.Id }); + var createdKeyId = await _keyRepository.CreateAsync(keyCredential); - return CreatedAtAction( - nameof(GetProviderKeyCredential), - new { providerId = providerId, keyId = createdKey.Id }, - new + // After CreateAsync, keyCredential has its Id populated and IsPrimary potentially modified + // Publish key created event + PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialCreated { - createdKey.Id, - createdKey.ProviderId, - createdKey.KeyName, - createdKey.IsPrimary, - createdKey.IsEnabled, - createdKey.ProviderAccountGroup, - ApiKey = createdKey.ApiKey != null ? "***" + createdKey.ApiKey.Substring(Math.Max(0, createdKey.ApiKey.Length - 4)) : "***", - createdKey.Organization, - createdKey.BaseUrl, - createdKey.CreatedAt, - createdKey.UpdatedAt - }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning("Controller caught InvalidOperationException of type {ExceptionType} for provider {ProviderId}: {Message}", - ex.GetType().FullName, providerId, ex.Message); - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Controller caught general Exception of type {ExceptionType} for provider {ProviderId}: {Message}", - ex.GetType().FullName, providerId, ex.Message); - return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponseDto("An unexpected error occurred.")); - } + KeyId = createdKeyId, + ProviderId = providerId, + IsPrimary = keyCredential.IsPrimary, + IsEnabled = keyCredential.IsEnabled, + CorrelationId = Guid.NewGuid() + }, "create provider key", new { ProviderId = providerId, KeyId = createdKeyId }); + + LogAdminAudit("Created", "ProviderKeyCredential", createdKeyId, $"Provider: {providerId}, KeyName: {LoggingSanitizer.S(keyCredential.KeyName)}"); + AdminOperationsMetricsService.RecordConfigurationChange("providerkey", "create"); + + return CreatedAtAction( + nameof(GetProviderKeyCredential), + new { providerId = providerId, keyId = createdKeyId }, + new + { + Id = createdKeyId, + keyCredential.ProviderId, + keyCredential.KeyName, + keyCredential.IsPrimary, + keyCredential.IsEnabled, + keyCredential.ProviderAccountGroup, + ApiKey = keyCredential.ApiKey != null ? "***" + keyCredential.ApiKey.Substring(Math.Max(0, keyCredential.ApiKey.Length - 4)) : "***", + keyCredential.Organization, + keyCredential.BaseUrl, + keyCredential.CreatedAt, + keyCredential.UpdatedAt + }); + }, + "CreateProviderKeyCredential", + new { ProviderId = providerId }); } /// @@ -184,63 +175,88 @@ public async Task CreateProviderKeyCredential(int providerId, [Fr [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateProviderKeyCredential(int providerId, int keyId, [FromBody] UpdateKeyRequest request) + public Task UpdateProviderKeyCredential(int providerId, int keyId, [FromBody] UpdateKeyRequest request) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var key = await _keyRepository.GetByIdAsync(keyId); - if (key == null || key.ProviderId != providerId) + return ExecuteAsync( + async () => { - _logger.LogWarning("Key credential not found for update {KeyId}", keyId); - return NotFound(new ErrorResponseDto("Key credential not found")); - } + var key = await _keyRepository.GetByIdAsync(keyId); + if (key == null || key.ProviderId != providerId) + { + Logger.LogWarning("Key credential not found for update {KeyId}", keyId); + return this.NotFoundEntity("Key credential", keyId); + } - // Update fields - if (!string.IsNullOrEmpty(request.KeyName)) - key.KeyName = request.KeyName; - if (!string.IsNullOrEmpty(request.ApiKey)) - key.ApiKey = request.ApiKey; - if (request.Organization != null) - key.Organization = request.Organization; - if (request.BaseUrl != null) - key.BaseUrl = request.BaseUrl; - if (request.IsPrimary.HasValue) - key.IsPrimary = request.IsPrimary.Value; - if (request.IsEnabled.HasValue) - key.IsEnabled = request.IsEnabled.Value; - if (request.ProviderAccountGroup.HasValue) - key.ProviderAccountGroup = (short)request.ProviderAccountGroup.Value; - - key.UpdatedAt = DateTime.UtcNow; + // Track changes with before/after values + var changes = new List<(string Property, string? OldValue, string? NewValue)>(); - await _keyRepository.UpdateAsync(key); + if (!string.IsNullOrEmpty(request.KeyName) && key.KeyName != request.KeyName) + { + changes.Add(("KeyName", key.KeyName, request.KeyName)); + key.KeyName = request.KeyName; + } + if (!string.IsNullOrEmpty(request.ApiKey)) + { + changes.Add(("ApiKey", "***", "***")); // Never log API key values + key.ApiKey = request.ApiKey; + } + if (request.Organization != null && key.Organization != request.Organization) + { + changes.Add(("Organization", key.Organization, request.Organization)); + key.Organization = request.Organization; + } + if (request.BaseUrl != null && key.BaseUrl != request.BaseUrl) + { + changes.Add(("BaseUrl", key.BaseUrl, request.BaseUrl)); + key.BaseUrl = request.BaseUrl; + } + if (request.IsPrimary.HasValue && key.IsPrimary != request.IsPrimary.Value) + { + changes.Add(("IsPrimary", key.IsPrimary.ToString(), request.IsPrimary.Value.ToString())); + key.IsPrimary = request.IsPrimary.Value; + } + if (request.IsEnabled.HasValue && key.IsEnabled != request.IsEnabled.Value) + { + changes.Add(("IsEnabled", key.IsEnabled.ToString(), request.IsEnabled.Value.ToString())); + key.IsEnabled = request.IsEnabled.Value; + } + if (request.ProviderAccountGroup.HasValue && key.ProviderAccountGroup != (short)request.ProviderAccountGroup.Value) + { + changes.Add(("ProviderAccountGroup", key.ProviderAccountGroup.ToString(), request.ProviderAccountGroup.Value.ToString())); + key.ProviderAccountGroup = (short)request.ProviderAccountGroup.Value; + } - // Publish key updated event - PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialUpdated - { - KeyId = keyId, - ProviderId = providerId, - ChangedProperties = new[] { "KeyName", "Organization", "ProviderAccountGroup", "BaseUrl", "IsPrimary", "IsEnabled" }, - CorrelationId = Guid.NewGuid() - }, "update provider key", new { ProviderId = providerId, KeyId = keyId }); + key.UpdatedAt = DateTime.UtcNow; + + await _keyRepository.UpdateAsync(key); + + var changedProperties = changes.Count > 0 + ? changes.Select(c => c.Property).ToArray() + : Array.Empty(); + + if (changes.Count > 0) + { + LogAdminAuditWithChanges("ProviderKeyCredential", keyId, changes, $"Provider: {providerId}"); + } + else + { + LogAdminAudit("Updated", "ProviderKeyCredential", keyId, $"Provider: {providerId} (no changes detected)"); + } + AdminOperationsMetricsService.RecordConfigurationChange("providerkey", "update"); + + // Publish key updated event + PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialUpdated + { + KeyId = keyId, + ProviderId = providerId, + ChangedProperties = changedProperties, + CorrelationId = Guid.NewGuid() + }, "update provider key", new { ProviderId = providerId, KeyId = keyId }); - return NoContent(); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation when updating key credential {KeyId}", keyId); - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating key credential {KeyId}", keyId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return NoContent(); + }, + "UpdateProviderKeyCredential", + new { ProviderId = providerId, KeyId = keyId }); } /// @@ -253,39 +269,35 @@ public async Task UpdateProviderKeyCredential(int providerId, int [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteProviderKeyCredential(int providerId, int keyId) + public Task DeleteProviderKeyCredential(int providerId, int keyId) { - try - { - var key = await _keyRepository.GetByIdAsync(keyId); - if (key == null || key.ProviderId != providerId) + return ExecuteAsync( + async () => { - _logger.LogWarning("Key credential not found for deletion {KeyId}", keyId); - return NotFound(new ErrorResponseDto("Key credential not found")); - } + var key = await _keyRepository.GetByIdAsync(keyId); + if (key == null || key.ProviderId != providerId) + { + Logger.LogWarning("Key credential not found for deletion {KeyId}", keyId); + return this.NotFoundEntity("Key credential", keyId); + } - await _keyRepository.DeleteAsync(keyId); + await _keyRepository.DeleteAsync(keyId); - // Publish key deleted event - PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialDeleted - { - KeyId = keyId, - ProviderId = providerId, - CorrelationId = Guid.NewGuid() - }, "delete provider key", new { ProviderId = providerId, KeyId = keyId }); + LogAdminAudit("Deleted", "ProviderKeyCredential", keyId, $"Provider: {providerId}, KeyName: {LoggingSanitizer.S(key.KeyName)}"); + AdminOperationsMetricsService.RecordConfigurationChange("providerkey", "delete"); - return NoContent(); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation when deleting key credential {KeyId}", keyId); - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting key credential {KeyId}", keyId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + // Publish key deleted event + PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialDeleted + { + KeyId = keyId, + ProviderId = providerId, + CorrelationId = Guid.NewGuid() + }, "delete provider key", new { ProviderId = providerId, KeyId = keyId }); + + return NoContent(); + }, + "DeleteProviderKeyCredential", + new { ProviderId = providerId, KeyId = keyId }); } /// @@ -299,52 +311,49 @@ public async Task DeleteProviderKeyCredential(int providerId, int [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task SetPrimaryKey(int providerId, int keyId) + public Task SetPrimaryKey(int providerId, int keyId) { - try - { - var key = await _keyRepository.GetByIdAsync(keyId); - if (key == null || key.ProviderId != providerId) + return ExecuteAsync( + async () => { - _logger.LogWarning("Key credential not found {KeyId} for provider {ProviderId}", keyId, providerId); - return NotFound(new ErrorResponseDto("Key credential not found")); - } + var key = await _keyRepository.GetByIdAsync(keyId); + if (key == null || key.ProviderId != providerId) + { + Logger.LogWarning("Key credential not found {KeyId} for provider {ProviderId}", keyId, providerId); + return this.NotFoundEntity("Key credential", keyId); + } - // Unset all other primary keys for this provider - var allKeys = await _keyRepository.GetByProviderIdAsync(providerId); - foreach (var otherKey in allKeys.Where(k => k.IsPrimary && k.Id != keyId)) - { - otherKey.IsPrimary = false; - otherKey.UpdatedAt = DateTime.UtcNow; - await _keyRepository.UpdateAsync(otherKey); - } + // Unset all other primary keys for this provider + var allKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _keyRepository.GetByProviderIdPaginatedAsync, providerId); + foreach (var otherKey in allKeys.Where(k => k.IsPrimary && k.Id != keyId)) + { + otherKey.IsPrimary = false; + otherKey.UpdatedAt = DateTime.UtcNow; + await _keyRepository.UpdateAsync(otherKey); + } - // Set this key as primary - key.IsPrimary = true; - key.UpdatedAt = DateTime.UtcNow; - await _keyRepository.UpdateAsync(key); + // Set this key as primary + key.IsPrimary = true; + key.UpdatedAt = DateTime.UtcNow; + await _keyRepository.UpdateAsync(key); - // Publish primary key changed event - PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialPrimaryChanged - { - ProviderId = providerId, - OldPrimaryKeyId = 0, // Not tracking old primary in this method - NewPrimaryKeyId = keyId, - CorrelationId = Guid.NewGuid() - }, "set primary key", new { ProviderId = providerId, KeyId = keyId }); + LogAdminAudit("SetPrimary", "ProviderKeyCredential", keyId, $"Provider: {providerId}, KeyName: {LoggingSanitizer.S(key.KeyName)}"); + AdminOperationsMetricsService.RecordConfigurationChange("providerkey", "set_primary"); + + // Publish primary key changed event + PublishEventFireAndForget(new ConduitLLM.Configuration.Events.ProviderKeyCredentialPrimaryChanged + { + ProviderId = providerId, + OldPrimaryKeyId = 0, // Not tracking old primary in this method + NewPrimaryKeyId = keyId, + CorrelationId = Guid.NewGuid() + }, "set primary key", new { ProviderId = providerId, KeyId = keyId }); - return NoContent(); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation when setting primary key {KeyId} for provider {ProviderId}", keyId, providerId); - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error setting primary key {KeyId} for provider {ProviderId}", keyId, providerId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return NoContent(); + }, + "SetPrimaryKey", + new { ProviderId = providerId, KeyId = keyId }); } } } diff --git a/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Providers.cs b/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Providers.cs index 94eee1780..8df5a9a5b 100644 --- a/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Providers.cs +++ b/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Providers.cs @@ -1,11 +1,13 @@ +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using MassTransit; using Microsoft.AspNetCore.Authorization; using ConduitLLM.Configuration.DTOs; using Microsoft.AspNetCore.Mvc; -using ConduitLLM.Core.Controllers; using ConduitLLM.Core.Events; using ConduitLLM.Configuration.Interfaces; @@ -17,12 +19,11 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] - public partial class ProviderCredentialsController : EventPublishingControllerBase + public partial class ProviderCredentialsController : AdminControllerBase { private readonly IProviderRepository _providerRepository; private readonly IProviderKeyCredentialRepository _keyRepository; private readonly ILLMClientFactory _clientFactory; - private readonly ILogger _logger; /// /// Initializes a new instance of the ProviderCredentialsController @@ -38,39 +39,55 @@ public ProviderCredentialsController( _providerRepository = providerRepository ?? throw new ArgumentNullException(nameof(providerRepository)); _keyRepository = keyRepository ?? throw new ArgumentNullException(nameof(keyRepository)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - /// Gets all provider configurations + /// Gets all provider configurations with pagination /// - /// List of all providers + /// Page number (1-based, default: 1) + /// Number of items per page (default: 50, max: 100) + /// Cancellation token + /// Paginated list of providers [HttpGet] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Configuration.DTOs.PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetAllProviders() + public Task GetAllProviders( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + CancellationToken cancellationToken = default) { - try - { - var providers = await _providerRepository.GetAllAsync(); - var result = providers.Select(p => new + // Validate and clamp page parameters + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > 100) pageSize = 100; + + return ExecuteAsync( + async () => { - p.Id, - p.ProviderType, - p.ProviderName, - p.BaseUrl, - p.IsEnabled, - p.CreatedAt, - p.UpdatedAt, - KeyCount = p.ProviderKeyCredentials?.Count ?? 0 - }); - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all providers"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + var (providers, totalCount) = await _providerRepository.GetPaginatedAsync(page, pageSize, cancellationToken); + var items = providers.Select(p => new + { + p.Id, + p.ProviderType, + p.ProviderName, + p.BaseUrl, + p.IsEnabled, + p.CreatedAt, + p.UpdatedAt, + KeyCount = p.ProviderKeyCredentials?.Count ?? 0 + }).ToList(); + + return new Configuration.DTOs.PagedResult + { + Items = items.Cast().ToList(), + TotalCount = totalCount, + CurrentPage = page, + PageSize = pageSize, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + }, + result => Ok(result), + "GetAllProviders"); } /// @@ -82,19 +99,11 @@ public async Task GetAllProviders() [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetProviderById(int id) + public Task GetProviderById(int id) { - try - { - var provider = await _providerRepository.GetByIdAsync(id); - - if (provider == null) - { - _logger.LogWarning("Provider not found {ProviderId}", id); - return NotFound(new ErrorResponseDto("Provider not found")); - } - - return Ok(new + return ExecuteWithNotFoundAsync( + () => _providerRepository.GetByIdAsync(id), + provider => Ok(new { provider.Id, provider.ProviderType, @@ -104,13 +113,10 @@ public async Task GetProviderById(int id) provider.CreatedAt, provider.UpdatedAt, KeyCount = provider.ProviderKeyCredentials?.Count ?? 0 - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting provider with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + }), + "Provider", + id, + "GetProviderById"); } /// @@ -121,41 +127,43 @@ public async Task GetProviderById(int id) [ProducesResponseType(typeof(object), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CreateProvider([FromBody] CreateProviderRequest request) + public Task CreateProvider([FromBody] CreateProviderRequest request) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var provider = new Provider + return ExecuteAsync( + async () => { - ProviderType = request.ProviderType, - ProviderName = request.ProviderName, - BaseUrl = request.BaseUrl, - IsEnabled = request.IsEnabled, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; + var provider = new Provider + { + ProviderType = request.ProviderType, + ProviderName = request.ProviderName, + BaseUrl = request.BaseUrl, + IsEnabled = request.IsEnabled, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; - var id = await _providerRepository.CreateAsync(provider); - provider.Id = id; + var id = await _providerRepository.CreateAsync(provider); + provider.Id = id; - // Publish provider created event - PublishEventFireAndForget(new ProviderCreated - { - ProviderId = id, - ProviderType = provider.ProviderType.ToString(), - ProviderName = provider.ProviderName, - BaseUrl = provider.BaseUrl, - IsEnabled = provider.IsEnabled, - CreatedAt = provider.CreatedAt, - CorrelationId = Guid.NewGuid().ToString() - }, "create provider"); + // Publish provider created event + PublishEventFireAndForget(new ProviderCreated + { + ProviderId = id, + ProviderType = provider.ProviderType.ToString(), + ProviderName = provider.ProviderName, + BaseUrl = provider.BaseUrl, + IsEnabled = provider.IsEnabled, + CreatedAt = provider.CreatedAt, + CorrelationId = Guid.NewGuid().ToString() + }, "create provider"); - return CreatedAtAction(nameof(GetProviderById), new { id = provider.Id }, new + LogAdminAudit("Created", "Provider", id, $"Type: {provider.ProviderType}, Name: {LoggingSanitizer.S(provider.ProviderName)}"); + AdminOperationsMetricsService.RecordProviderOperation("create", provider.ProviderType.ToString(), "success"); + AdminOperationsMetricsService.RecordConfigurationChange("provider", "create"); + + return provider; + }, + provider => CreatedAtAction(nameof(GetProviderById), new { id = provider.Id }, new { provider.Id, provider.ProviderType, @@ -165,18 +173,8 @@ public async Task CreateProvider([FromBody] CreateProviderRequest provider.CreatedAt, provider.UpdatedAt, KeyCount = 0 - }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation when creating provider"); - return BadRequest(ex.Message); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating provider"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + }), + "CreateProvider"); } /// @@ -190,65 +188,59 @@ public async Task CreateProvider([FromBody] CreateProviderRequest [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateProvider(int id, [FromBody] UpdateProviderRequest request) + public Task UpdateProvider(int id, [FromBody] UpdateProviderRequest request) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var provider = await _providerRepository.GetByIdAsync(id); - if (provider == null) + return ExecuteWithNotFoundAsync( + () => _providerRepository.GetByIdAsync(id), + async provider => { - _logger.LogWarning("Provider not found for update {ProviderId}", id); - return NotFound(new ErrorResponseDto("Provider not found")); - } + var changes = new List<(string Property, string? OldValue, string? NewValue)>(); - var changedProperties = new List(); - - if (!string.IsNullOrEmpty(request.ProviderName) && provider.ProviderName != request.ProviderName) - { - provider.ProviderName = request.ProviderName; - changedProperties.Add("ProviderName"); - } - - if (provider.BaseUrl != request.BaseUrl) - { - provider.BaseUrl = request.BaseUrl; - changedProperties.Add("BaseUrl"); - } - - if (provider.IsEnabled != request.IsEnabled) - { - provider.IsEnabled = request.IsEnabled; - changedProperties.Add("IsEnabled"); - } + if (!string.IsNullOrEmpty(request.ProviderName) && provider.ProviderName != request.ProviderName) + { + changes.Add(("ProviderName", provider.ProviderName, request.ProviderName)); + provider.ProviderName = request.ProviderName; + } - provider.UpdatedAt = DateTime.UtcNow; - - await _providerRepository.UpdateAsync(provider); + if (provider.BaseUrl != request.BaseUrl) + { + changes.Add(("BaseUrl", provider.BaseUrl, request.BaseUrl)); + provider.BaseUrl = request.BaseUrl; + } - // Publish provider updated event - if (changedProperties.Count() > 0) - { - PublishEventFireAndForget(new ProviderUpdated + if (provider.IsEnabled != request.IsEnabled) { - ProviderId = id, - IsEnabled = provider.IsEnabled, - ChangedProperties = changedProperties.ToArray(), - CorrelationId = Guid.NewGuid().ToString() - }, "update provider", new { ProviderId = id, ChangedProperties = string.Join(", ", changedProperties) }); - } + changes.Add(("IsEnabled", provider.IsEnabled.ToString(), request.IsEnabled.ToString())); + provider.IsEnabled = request.IsEnabled; + } + + provider.UpdatedAt = DateTime.UtcNow; + + await _providerRepository.UpdateAsync(provider); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating provider with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + // Publish provider updated event + if (changes.Count > 0) + { + var changedProperties = changes.Select(c => c.Property).ToArray(); + PublishEventFireAndForget(new ProviderUpdated + { + ProviderId = id, + IsEnabled = provider.IsEnabled, + ChangedProperties = changedProperties, + CorrelationId = Guid.NewGuid().ToString() + }, "update provider", new { ProviderId = id, ChangedProperties = string.Join(", ", changedProperties) }); + + LogAdminAuditWithChanges("Provider", id, changes); + } + + AdminOperationsMetricsService.RecordProviderOperation("update", provider.ProviderType.ToString(), "success"); + AdminOperationsMetricsService.RecordConfigurationChange("provider", "update"); + + return NoContent(); + }, + "Provider", + id, + "UpdateProvider"); } /// @@ -260,33 +252,30 @@ public async Task UpdateProvider(int id, [FromBody] UpdateProvide [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteProvider(int id) + public Task DeleteProvider(int id) { - try - { - var provider = await _providerRepository.GetByIdAsync(id); - if (provider == null) + return ExecuteWithNotFoundAsync( + () => _providerRepository.GetByIdAsync(id), + async provider => { - _logger.LogWarning("Provider not found for deletion {ProviderId}", id); - return NotFound(new ErrorResponseDto("Provider not found")); - } + await _providerRepository.DeleteAsync(id); - await _providerRepository.DeleteAsync(id); + // Publish provider deleted event + PublishEventFireAndForget(new ProviderDeleted + { + ProviderId = id, + CorrelationId = Guid.NewGuid().ToString() + }, "delete provider", new { ProviderId = id }); - // Publish provider deleted event - PublishEventFireAndForget(new ProviderDeleted - { - ProviderId = id, - CorrelationId = Guid.NewGuid().ToString() - }, "delete provider", new { ProviderId = id }); + LogAdminAudit("Deleted", "Provider", id, $"Name: {LoggingSanitizer.S(provider.ProviderName)}"); + AdminOperationsMetricsService.RecordProviderOperation("delete", provider.ProviderType.ToString(), "success"); + AdminOperationsMetricsService.RecordConfigurationChange("provider", "delete"); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting provider with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return NoContent(); + }, + "Provider", + id, + "DeleteProvider"); } } } diff --git a/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Testing.cs b/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Testing.cs index d2d4f9d5f..1c4e36d23 100644 --- a/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Testing.cs +++ b/Services/ConduitLLM.Admin/Controllers/ProviderCredentialsController.Testing.cs @@ -1,5 +1,6 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Admin.Metrics; using ConduitLLM.Admin.Services; using Microsoft.AspNetCore.Mvc; @@ -16,60 +17,63 @@ public partial class ProviderCredentialsController [ProducesResponseType(typeof(StandardApiKeyTestResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task TestProviderConnection(int id) + public Task TestProviderConnection(int id) { - try - { - var provider = await _providerRepository.GetByIdAsync(id); - if (provider == null) + return ExecuteWithNotFoundAsync( + () => _providerRepository.GetByIdAsync(id), + async provider => { - return NotFound(new ErrorResponseDto("Provider not found")); - } - - // Check if this provider type doesn't support testing - var nonTestableResponse = ApiKeyTestResultService.CreateErrorResponse( - new NotSupportedException("Provider does not support API key testing"), - provider.ProviderType - ); - - if (nonTestableResponse.Result == ApiKeyTestResult.Ignored) - { - return Ok(nonTestableResponse); - } - - // Get a client for this provider to test - var client = _clientFactory.GetClientByProviderId(id); - - // Perform a simple test - list models - var startTime = DateTime.UtcNow; - try - { - var models = await client.ListModelsAsync(); - var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; - var modelList = models?.Select(m => m.ToString()).ToArray(); - - var response = ApiKeyTestResultService.CreateSuccessResponse( - responseTime, - modelList - ); - - return Ok(response); - } - catch (Exception testEx) - { - var response = ApiKeyTestResultService.CreateErrorResponse( - testEx, + // Check if this provider type doesn't support testing + var nonTestableResponse = ApiKeyTestResultService.CreateErrorResponse( + new NotSupportedException("Provider does not support API key testing"), provider.ProviderType ); - - return Ok(response); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error testing connection for provider with ID {Id}", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + + if (nonTestableResponse.Result == ApiKeyTestResult.Ignored) + { + return Ok(nonTestableResponse); + } + + // Get a client for this provider to test + var client = await _clientFactory.GetClientByProviderIdAsync(id); + + // Perform a simple test - list models + using var activity = AdminRequestMetrics.StartProviderTestActivity(provider.ProviderType.ToString(), id); + var startTime = DateTime.UtcNow; + try + { + var models = await client.ListModelsAsync(); + var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; + var modelList = models?.Select(m => m.ToString()).ToArray(); + + var response = ApiKeyTestResultService.CreateSuccessResponse( + responseTime, + modelList + ); + + LogAdminAudit("Tested", "Provider", id, + $"Type: {provider.ProviderType}, Result: Success, ResponseTime: {responseTime:F0}ms, Models: {modelList?.Length ?? 0}"); + AdminOperationsMetricsService.RecordProviderOperation("test", provider.ProviderType.ToString(), "success", responseTime / 1000.0); + + return Ok(response); + } + catch (Exception testEx) + { + var response = ApiKeyTestResultService.CreateErrorResponse( + testEx, + provider.ProviderType + ); + + LogAdminAudit("Tested", "Provider", id, + $"Type: {provider.ProviderType}, Result: {response.Result}, Error: {response.Message}"); + AdminOperationsMetricsService.RecordProviderOperation("test", provider.ProviderType.ToString(), "failure"); + + return Ok(response); + } + }, + "Provider", + id, + "TestProviderConnection"); } /// @@ -80,91 +84,85 @@ public async Task TestProviderConnection(int id) [ProducesResponseType(typeof(StandardApiKeyTestResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task TestProviderConnectionWithCredentials([FromBody] TestProviderRequest testRequest) + public Task TestProviderConnectionWithCredentials([FromBody] TestProviderRequest testRequest) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - // Create a temporary provider for testing - var testProvider = new Provider - { - Id = -1, // Temporary ID - ProviderType = testRequest.ProviderType, - ProviderName = "Test Provider", - BaseUrl = testRequest.BaseUrl, - IsEnabled = true - }; - - // Create a temporary key if provided - if (!string.IsNullOrEmpty(testRequest.ApiKey)) + return ExecuteAsync( + async () => { - testProvider.ProviderKeyCredentials = new List + // Create a temporary provider for testing + var testProvider = new Provider { - new ProviderKeyCredential - { - ApiKey = testRequest.ApiKey, - Organization = testRequest.Organization, - IsPrimary = true, - IsEnabled = true - } + Id = -1, // Temporary ID + ProviderType = testRequest.ProviderType, + ProviderName = "Test Provider", + BaseUrl = testRequest.BaseUrl, + IsEnabled = true }; - } - // Check if this provider type doesn't support testing - var nonTestableResponse = ApiKeyTestResultService.CreateErrorResponse( - new NotSupportedException("Provider does not support API key testing"), - testProvider.ProviderType - ); + // Create a temporary key if provided + if (!string.IsNullOrEmpty(testRequest.ApiKey)) + { + testProvider.ProviderKeyCredentials = new List + { + new ProviderKeyCredential + { + ApiKey = testRequest.ApiKey, + Organization = testRequest.Organization, + IsPrimary = true, + IsEnabled = true + } + }; + } - if (nonTestableResponse.Result == ApiKeyTestResult.Ignored) - { - return Ok(nonTestableResponse); - } - - // Test the connection - var testKey = new ProviderKeyCredential - { - ApiKey = testRequest.ApiKey, - BaseUrl = testRequest.BaseUrl, - Organization = testRequest.Organization, - IsPrimary = true, - IsEnabled = true - }; - var client = _clientFactory.CreateTestClient(testProvider, testKey); - - var startTime = DateTime.UtcNow; - try - { - var models = await client.ListModelsAsync(); - var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; - var modelList = models?.Select(m => m.ToString()).ToArray(); - - var response = ApiKeyTestResultService.CreateSuccessResponse( - responseTime, - modelList - ); - - return Ok(response); - } - catch (Exception testEx) - { - var response = ApiKeyTestResultService.CreateErrorResponse( - testEx, + // Check if this provider type doesn't support testing + var nonTestableResponse = ApiKeyTestResultService.CreateErrorResponse( + new NotSupportedException("Provider does not support API key testing"), testProvider.ProviderType ); - - return Ok(response); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error testing connection for provider {ProviderType}", testRequest?.ProviderType.ToString() ?? "unknown"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + + if (nonTestableResponse.Result == ApiKeyTestResult.Ignored) + { + return (IActionResult)Ok(nonTestableResponse); + } + + // Test the connection + var testKey = new ProviderKeyCredential + { + ApiKey = testRequest.ApiKey, + BaseUrl = testRequest.BaseUrl, + Organization = testRequest.Organization, + IsPrimary = true, + IsEnabled = true + }; + var client = _clientFactory.CreateTestClient(testProvider, testKey); + + var startTime = DateTime.UtcNow; + try + { + var models = await client.ListModelsAsync(); + var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; + var modelList = models?.Select(m => m.ToString()).ToArray(); + + var response = ApiKeyTestResultService.CreateSuccessResponse( + responseTime, + modelList + ); + + return (IActionResult)Ok(response); + } + catch (Exception testEx) + { + var response = ApiKeyTestResultService.CreateErrorResponse( + testEx, + testProvider.ProviderType + ); + + return (IActionResult)Ok(response); + } + }, + result => result, + "TestProviderConnectionWithCredentials", + new { ProviderType = testRequest?.ProviderType.ToString() ?? "unknown" }); } /// @@ -177,65 +175,73 @@ public async Task TestProviderConnectionWithCredentials([FromBody [ProducesResponseType(typeof(StandardApiKeyTestResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task TestProviderKeyCredential(int providerId, int keyId) + public Task TestProviderKeyCredential(int providerId, int keyId) { - try - { - var key = await _keyRepository.GetByIdAsync(keyId); - if (key == null || key.ProviderId != providerId) - { - return NotFound(new ErrorResponseDto("Key credential not found")); - } - - var provider = await _providerRepository.GetByIdAsync(providerId); - if (provider == null) + return ExecuteAsync( + async () => { - return NotFound(new ErrorResponseDto("Provider not found")); - } + var key = await _keyRepository.GetByIdAsync(keyId); + if (key == null || key.ProviderId != providerId) + { + return (IActionResult)NotFound(new ErrorResponseDto("Key credential not found")); + } - // Check if this provider type doesn't support testing - var nonTestableResponse = ApiKeyTestResultService.CreateErrorResponse( - new NotSupportedException("Provider does not support API key testing"), - provider.ProviderType - ); + var provider = await _providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + return (IActionResult)NotFound(new ErrorResponseDto("Provider not found")); + } - if (nonTestableResponse.Result == ApiKeyTestResult.Ignored) - { - return Ok(nonTestableResponse); - } - - // Test the connection with this specific key - var client = _clientFactory.CreateTestClient(provider, key); - - var startTime = DateTime.UtcNow; - try - { - var models = await client.ListModelsAsync(); - var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; - var modelList = models?.Select(m => m.ToString()).ToArray(); - - var response = ApiKeyTestResultService.CreateSuccessResponse( - responseTime, - modelList - ); - - return Ok(response); - } - catch (Exception testEx) - { - var response = ApiKeyTestResultService.CreateErrorResponse( - testEx, + // Check if this provider type doesn't support testing + var nonTestableResponse = ApiKeyTestResultService.CreateErrorResponse( + new NotSupportedException("Provider does not support API key testing"), provider.ProviderType ); - - return Ok(response); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error testing key credential {KeyId} for provider {ProviderId}", keyId, providerId); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + + if (nonTestableResponse.Result == ApiKeyTestResult.Ignored) + { + return (IActionResult)Ok(nonTestableResponse); + } + + // Test the connection with this specific key + var client = _clientFactory.CreateTestClient(provider, key); + + using var activity = AdminRequestMetrics.StartProviderTestActivity(provider.ProviderType.ToString(), providerId); + var startTime = DateTime.UtcNow; + try + { + var models = await client.ListModelsAsync(); + var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; + var modelList = models?.Select(m => m.ToString()).ToArray(); + + var response = ApiKeyTestResultService.CreateSuccessResponse( + responseTime, + modelList + ); + + LogAdminAudit("Tested", "ProviderKeyCredential", keyId, + $"ProviderId: {providerId}, Result: Success, ResponseTime: {responseTime:F0}ms"); + AdminOperationsMetricsService.RecordProviderOperation("test", provider.ProviderType.ToString(), "success", responseTime / 1000.0); + + return (IActionResult)Ok(response); + } + catch (Exception testEx) + { + var response = ApiKeyTestResultService.CreateErrorResponse( + testEx, + provider.ProviderType + ); + + LogAdminAudit("Tested", "ProviderKeyCredential", keyId, + $"ProviderId: {providerId}, Result: {response.Result}, Error: {response.Message}"); + AdminOperationsMetricsService.RecordProviderOperation("test", provider.ProviderType.ToString(), "failure"); + + return (IActionResult)Ok(response); + } + }, + result => result, + "TestProviderKeyCredential", + new { ProviderId = providerId, KeyId = keyId }); } } } diff --git a/Services/ConduitLLM.Admin/Controllers/ProviderErrorsController.cs b/Services/ConduitLLM.Admin/Controllers/ProviderErrorsController.cs index 0532c0317..cb7210c11 100644 --- a/Services/ConduitLLM.Admin/Controllers/ProviderErrorsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ProviderErrorsController.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using ConduitLLM.Admin.DTOs; using ConduitLLM.Configuration.Events; using ConduitLLM.Configuration.Interfaces; @@ -9,7 +5,6 @@ using MassTransit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace ConduitLLM.Admin.Controllers { @@ -19,13 +14,12 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/provider-errors")] [Authorize(Policy = "MasterKeyPolicy")] - public class ProviderErrorsController : ControllerBase + public class ProviderErrorsController : AdminControllerBase { private readonly IProviderErrorTrackingService _errorService; private readonly IProviderKeyCredentialRepository _keyRepo; private readonly IProviderRepository _providerRepo; private readonly IPublishEndpoint _publishEndpoint; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -36,12 +30,12 @@ public ProviderErrorsController( IProviderRepository providerRepo, IPublishEndpoint publishEndpoint, ILogger logger) + : base(publishEndpoint, logger) { _errorService = errorService ?? throw new ArgumentNullException(nameof(errorService)); _keyRepo = keyRepo ?? throw new ArgumentNullException(nameof(keyRepo)); _providerRepo = providerRepo ?? throw new ArgumentNullException(nameof(providerRepo)); _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -52,42 +46,39 @@ public ProviderErrorsController( /// Maximum number of errors to return (default: 100) /// List of recent provider errors [HttpGet("recent")] - public async Task>> GetRecentErrors( + public Task GetRecentErrors( [FromQuery] int? providerId = null, [FromQuery] int? keyId = null, [FromQuery] int limit = 100) { - try - { - if (limit > 1000) - limit = 1000; // Cap at 1000 for performance - - var errors = await _errorService.GetRecentErrorsAsync(providerId, keyId, limit); - - // Get provider and key names for display - var providers = await _providerRepo.GetAllAsync(); - var providerMap = providers.ToDictionary(p => p.Id, p => p.ProviderName); - - var dtos = errors.Select(e => new ProviderErrorDto + return ExecuteAsync( + async () => { - KeyCredentialId = e.KeyCredentialId, - ProviderId = e.ProviderId, - ProviderName = providerMap.GetValueOrDefault(e.ProviderId), - ErrorType = e.ErrorType.ToString(), - ErrorMessage = e.ErrorMessage, - HttpStatusCode = e.HttpStatusCode, - OccurredAt = e.OccurredAt, - IsFatal = e.IsFatal, - ModelName = e.ModelName - }).ToList(); - - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get recent errors"); - return StatusCode(500, new { error = "Failed to retrieve error data" }); - } + if (limit > 1000) + limit = 1000; // Cap at 1000 for performance + + var errors = await _errorService.GetRecentErrorsAsync(providerId, keyId, limit); + + // Get provider names for display using efficient lookup + var providerMap = await _providerRepo.GetProviderNameMapAsync(); + + var dtos = errors.Select(e => new ProviderErrorDto + { + KeyCredentialId = e.KeyCredentialId, + ProviderId = e.ProviderId, + ProviderName = providerMap.GetValueOrDefault(e.ProviderId), + ErrorType = e.ErrorType.ToString(), + ErrorMessage = e.ErrorMessage, + HttpStatusCode = e.HttpStatusCode, + OccurredAt = e.OccurredAt, + IsFatal = e.IsFatal, + ModelName = e.ModelName + }).ToList(); + + return dtos; + }, + result => Ok(result), + "GetRecentErrors"); } /// @@ -95,38 +86,52 @@ public async Task>> GetRecentErrors( /// /// List of provider error summaries [HttpGet("summary")] - public async Task>> GetErrorSummary() + public Task GetErrorSummary() { - try - { - var providers = await _providerRepo.GetAllAsync(); - var summaries = new List(); - - foreach (var provider in providers) + return ExecuteAsync( + async () => { - var summary = await _errorService.GetProviderSummaryAsync(provider.Id); - if (summary != null) + // Use paginated retrieval - get all providers in batches + var allProviders = new List(); + var pageNumber = 1; + const int pageSize = 100; + int totalCount; + + do { - summaries.Add(new ProviderErrorSummaryDto + var (items, count) = await _providerRepo.GetPaginatedAsync(pageNumber, pageSize); + allProviders.AddRange(items); + totalCount = count; + pageNumber++; + } while (allProviders.Count < totalCount); + + // Fetch all provider summaries in parallel to avoid N+1 + var summaryTasks = allProviders.Select(async provider => + { + var summary = await _errorService.GetProviderSummaryAsync(provider.Id); + return (provider, summary); + }); + + var results = await Task.WhenAll(summaryTasks); + + var summaries = results + .Where(r => r.summary != null) + .Select(r => new ProviderErrorSummaryDto { - ProviderId = provider.Id, - ProviderName = provider.ProviderName, - TotalErrors = summary.TotalErrors, - FatalErrors = summary.FatalErrors, - Warnings = summary.Warnings, - DisabledKeyIds = summary.DisabledKeyIds, - LastError = summary.LastError - }); - } - } + ProviderId = r.provider.Id, + ProviderName = r.provider.ProviderName, + TotalErrors = r.summary!.TotalErrors, + FatalErrors = r.summary.FatalErrors, + Warnings = r.summary.Warnings, + DisabledKeyIds = r.summary.DisabledKeyIds, + LastError = r.summary.LastError + }) + .ToList(); - return Ok(summaries); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get error summary"); - return StatusCode(500, new { error = "Failed to retrieve error summary" }); - } + return summaries; + }, + result => Ok(result), + "GetErrorSummary"); } /// @@ -135,51 +140,50 @@ public async Task>> GetErrorSummary() /// ID of the key /// Detailed error information for the key [HttpGet("keys/{keyId}")] - public async Task> GetKeyErrors(int keyId) + public Task GetKeyErrors(int keyId) { - try - { - var details = await _errorService.GetKeyErrorDetailsAsync(keyId); - if (details == null) + return ExecuteAsync( + async () => { - return NotFound(new { error = $"No error data found for key {keyId}" }); - } - - var dto = new KeyErrorDetailsDto - { - KeyId = details.KeyId, - KeyName = details.KeyName, - IsDisabled = details.IsDisabled, - DisabledAt = details.DisabledAt - }; + var details = await _errorService.GetKeyErrorDetailsAsync(keyId); + if (details == null) + { + throw new KeyNotFoundException($"No error data found for key {keyId}"); + } - if (details.FatalError != null) - { - dto.FatalError = new FatalErrorDto + var dto = new KeyErrorDetailsDto { - ErrorType = details.FatalError.ErrorType.ToString(), - Count = details.FatalError.Count, - FirstSeen = details.FatalError.FirstSeen, - LastSeen = details.FatalError.LastSeen, - LastErrorMessage = details.FatalError.LastErrorMessage, - LastStatusCode = details.FatalError.LastStatusCode + KeyId = details.KeyId, + KeyName = details.KeyName, + IsDisabled = details.IsDisabled, + DisabledAt = details.DisabledAt }; - } - dto.RecentWarnings = details.RecentWarnings.Select(w => new WarningErrorDto - { - Type = w.Type.ToString(), - Message = w.Message, - Timestamp = w.Timestamp - }).ToList(); + if (details.FatalError != null) + { + dto.FatalError = new FatalErrorDto + { + ErrorType = details.FatalError.ErrorType.ToString(), + Count = details.FatalError.Count, + FirstSeen = details.FatalError.FirstSeen, + LastSeen = details.FatalError.LastSeen, + LastErrorMessage = details.FatalError.LastErrorMessage, + LastStatusCode = details.FatalError.LastStatusCode + }; + } - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get key errors for key {KeyId}", keyId); - return StatusCode(500, new { error = "Failed to retrieve key error data" }); - } + dto.RecentWarnings = details.RecentWarnings.Select(w => new WarningErrorDto + { + Type = w.Type.ToString(), + Message = w.Message, + Timestamp = w.Timestamp + }).ToList(); + + return dto; + }, + result => Ok(result), + "GetKeyErrors", + new { KeyId = keyId }); } /// @@ -189,65 +193,61 @@ public async Task> GetKeyErrors(int keyId) /// Clear errors request /// Operation result [HttpPost("keys/{keyId}/clear")] - public async Task ClearKeyErrors( + public Task ClearKeyErrors( int keyId, [FromBody] ClearErrorsRequest request) { - try + if (!request.ConfirmReenable && request.ReenableKey) { - if (!request.ConfirmReenable && request.ReenableKey) - { - return BadRequest(new { error = "Must confirm re-enabling the key" }); - } - - // Clear errors from Redis - await _errorService.ClearErrorsForKeyAsync(keyId); - _logger.LogInformation("Cleared errors for key {KeyId}", keyId); + return Task.FromResult(BadRequest(new { error = "Must confirm re-enabling the key" })); + } - // Re-enable the key if requested - if (request.ReenableKey) + return ExecuteAsync( + async () => { + // Look up the key to get its providerId for proper cleanup var key = await _keyRepo.GetByIdAsync(keyId); - if (key == null) - { - return NotFound(new { error = $"Key {keyId} not found" }); - } + int? providerId = key?.ProviderId; - if (!key.IsEnabled) + // Clear errors from Redis (including provider disabled keys cleanup) + await _errorService.ClearErrorsForKeyAsync(keyId, providerId); + + // Re-enable the key if requested + if (request.ReenableKey && key != null && !key.IsEnabled) { key.IsEnabled = true; await _keyRepo.UpdateAsync(key); // Publish event for UI update - await _publishEndpoint.Publish(new ProviderKeyReenabledEvent + PublishEventFireAndForget(new ProviderKeyReenabledEvent { KeyId = keyId, ProviderId = key.ProviderId, ReenabledBy = User.Identity?.Name ?? "Admin", Reason = request.Reason ?? "Manual re-enable after error resolution", ReenabledAt = DateTime.UtcNow - }); + }, "ClearKeyErrors"); - _logger.LogInformation( - "Re-enabled key {KeyId} for provider {ProviderId} by {User}", - keyId, key.ProviderId, User.Identity?.Name); + LogAdminAudit("ClearedErrorsAndReenabled", "ProviderKeyCredential", keyId, + $"ProviderId: {key.ProviderId}"); } - } - - return Ok(new - { - message = request.ReenableKey - ? "Errors cleared and key re-enabled successfully" - : "Errors cleared successfully", - keyId = keyId, - reenabled = request.ReenableKey - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to clear errors for key {KeyId}", keyId); - return StatusCode(500, new { error = "Failed to clear key errors" }); - } + else + { + LogAdminAudit("ClearedErrors", "ProviderKeyCredential", keyId); + } + + return new + { + message = request.ReenableKey + ? "Errors cleared and key re-enabled successfully" + : "Errors cleared successfully", + keyId = keyId, + reenabled = request.ReenableKey + }; + }, + result => Ok(result), + "ClearKeyErrors", + new { KeyId = keyId }); } /// @@ -256,40 +256,40 @@ await _publishEndpoint.Publish(new ProviderKeyReenabledEvent /// Time window in hours (default: 24) /// Error statistics [HttpGet("stats")] - public async Task> GetErrorStatistics( + public Task GetErrorStatistics( [FromQuery] int hours = 24) { - try - { - if (hours > 168) // Cap at 1 week - hours = 168; + return ExecuteAsync( + async () => + { + if (hours > 168) // Cap at 1 week + hours = 168; - var window = TimeSpan.FromHours(hours); - var stats = await _errorService.GetErrorStatisticsAsync(window); - - // Get provider names for the statistics - var providers = await _providerRepo.GetAllAsync(); - var providerNames = providers.ToDictionary(p => p.Id.ToString(), p => p.ProviderName); + var window = TimeSpan.FromHours(hours); + var stats = await _errorService.GetErrorStatisticsAsync(window); - var dto = new ErrorStatisticsDto - { - TotalErrors = stats.TotalErrors, - FatalErrors = stats.FatalErrors, - Warnings = stats.Warnings, - DisabledKeys = stats.DisabledKeys, - ErrorsByType = stats.ErrorsByType, - ErrorsByProvider = stats.ErrorsByProvider, - TimeWindow = window, - GeneratedAt = DateTime.UtcNow - }; - - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get error statistics"); - return StatusCode(500, new { error = "Failed to retrieve error statistics" }); - } + // Map provider IDs to names for the statistics + var providerNameMap = await _providerRepo.GetProviderNameMapAsync(); + var errorsByProviderName = stats.ErrorsByProvider.ToDictionary( + kvp => providerNameMap.GetValueOrDefault(int.Parse(kvp.Key), $"Provider {kvp.Key}"), + kvp => kvp.Value); + + var dto = new ErrorStatisticsDto + { + TotalErrors = stats.TotalErrors, + FatalErrors = stats.FatalErrors, + Warnings = stats.Warnings, + DisabledKeys = stats.DisabledKeys, + ErrorsByType = stats.ErrorsByType, + ErrorsByProvider = errorsByProviderName, + TimeWindow = window, + GeneratedAt = DateTime.UtcNow + }; + + return dto; + }, + result => Ok(result), + "GetErrorStatistics"); } /// @@ -299,25 +299,24 @@ public async Task> GetErrorStatistics( /// Time window in hours (default: 1) /// Dictionary of key ID to error count [HttpGet("providers/{providerId}/key-errors")] - public async Task>> GetErrorCountsByKey( + public Task GetErrorCountsByKey( int providerId, [FromQuery] int hours = 1) { - try - { - if (hours > 24) - hours = 24; // Cap at 24 hours + return ExecuteAsync( + async () => + { + if (hours > 24) + hours = 24; // Cap at 24 hours - var window = TimeSpan.FromHours(hours); - var counts = await _errorService.GetErrorCountsByKeyAsync(providerId, window); + var window = TimeSpan.FromHours(hours); + var counts = await _errorService.GetErrorCountsByKeyAsync(providerId, window); - return Ok(counts); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get error counts for provider {ProviderId}", providerId); - return StatusCode(500, new { error = "Failed to retrieve error counts" }); - } + return counts; + }, + result => Ok(result), + "GetErrorCountsByKey", + new { ProviderId = providerId }); } /// @@ -327,34 +326,32 @@ public async Task>> GetErrorCountsByKey( /// Reason for disabling /// Operation result [HttpPost("keys/{keyId}/disable")] - public async Task DisableKey( + public Task DisableKey( int keyId, [FromBody] string reason) { - try + if (string.IsNullOrWhiteSpace(reason)) { - if (string.IsNullOrWhiteSpace(reason)) - { - return BadRequest(new { error = "Reason is required for disabling a key" }); - } - - await _errorService.DisableKeyAsync(keyId, $"Manual disable: {reason}"); - - _logger.LogInformation( - "Manually disabled key {KeyId} by {User}: {Reason}", - keyId, User.Identity?.Name, reason); - - return Ok(new - { - message = "Key disabled successfully", - keyId = keyId - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to disable key {KeyId}", keyId); - return StatusCode(500, new { error = "Failed to disable key" }); + return Task.FromResult(BadRequest(new { error = "Reason is required for disabling a key" })); } + + return ExecuteAsync( + async () => + { + await _errorService.DisableKeyAsync(keyId, $"Manual disable: {reason}"); + + LogAdminAudit("Disabled", "ProviderKeyCredential", keyId, + $"Reason: {reason}"); + + return new + { + message = "Key disabled successfully", + keyId = keyId + }; + }, + result => Ok(result), + "DisableKey", + new { KeyId = keyId }); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Controllers/ProviderToolsController.cs b/Services/ConduitLLM.Admin/Controllers/ProviderToolsController.cs index 838a3bc98..553564be6 100644 --- a/Services/ConduitLLM.Admin/Controllers/ProviderToolsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/ProviderToolsController.cs @@ -3,6 +3,10 @@ using ConduitLLM.Configuration; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Core.Events; +using ConduitLLM.Core.Extensions; +using MassTransit; namespace ConduitLLM.Admin.Controllers { @@ -11,18 +15,22 @@ namespace ConduitLLM.Admin.Controllers /// [ApiController] [Route("api/admin/provider-tools")] - public class ProviderToolsController : ControllerBase + public class ProviderToolsController : AdminControllerBase { private readonly ConduitDbContext _context; - private readonly ILogger _logger; + private readonly IPublishEndpoint? _publishEndpoint; /// /// Initializes a new instance of the ProviderToolsController. /// - public ProviderToolsController(ConduitDbContext context, ILogger logger) + public ProviderToolsController( + ConduitDbContext context, + ILogger logger, + IPublishEndpoint? publishEndpoint = null) + : base(publishEndpoint, logger) { _context = context; - _logger = logger; + _publishEndpoint = publishEndpoint; } /// @@ -32,37 +40,34 @@ public ProviderToolsController(ConduitDbContext context, ILoggerOptional active status filter /// List of provider tools [HttpGet] - public async Task>> GetProviderTools( + public Task GetProviderTools( [FromQuery] ProviderType? provider = null, [FromQuery] bool? isActive = null) { - try - { - var query = _context.ProviderTools.AsQueryable(); - - if (provider.HasValue) + return ExecuteAsync( + async () => { - query = query.Where(pt => pt.Provider == provider.Value); - } + var query = _context.ProviderTools.AsQueryable(); - if (isActive.HasValue) - { - query = query.Where(pt => pt.IsActive == isActive.Value); - } + if (provider.HasValue) + { + query = query.Where(pt => pt.Provider == provider.Value); + } - var tools = await query - .OrderBy(pt => pt.Provider) - .ThenBy(pt => pt.ToolName) - .ToListAsync(); + if (isActive.HasValue) + { + query = query.Where(pt => pt.IsActive == isActive.Value); + } - var dtos = tools.Select(ProviderToolDto.FromEntity); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving provider tools"); - return StatusCode(500, new { error = "Failed to retrieve provider tools" }); - } + var tools = await query + .OrderBy(pt => pt.Provider) + .ThenBy(pt => pt.ToolName) + .ToListAsync(); + + return tools.Select(ProviderToolDto.FromEntity); + }, + result => Ok(result), + "GetProviderTools"); } /// @@ -71,23 +76,22 @@ public async Task>> GetProviderTools( /// Tool ID /// Provider tool details [HttpGet("{id}")] - public async Task> GetProviderTool(int id) + public Task GetProviderTool(int id) { - try - { - var tool = await _context.ProviderTools.FindAsync(id); - if (tool == null) + return ExecuteAsync( + async () => { - return NotFound(new { error = $"Provider tool with ID {id} not found" }); - } + var tool = await _context.ProviderTools.FindAsync(id); + if (tool == null) + { + throw new KeyNotFoundException($"Provider tool with ID '{id}' not found"); + } - return Ok(ProviderToolDto.FromEntity(tool)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving provider tool {Id}", id); - return StatusCode(500, new { error = "Failed to retrieve provider tool" }); - } + return ProviderToolDto.FromEntity(tool); + }, + result => Ok(result), + "GetProviderTool", + new { Id = id }); } /// @@ -96,46 +100,47 @@ public async Task> GetProviderTool(int id) /// Provider tool creation data /// Created provider tool [HttpPost] - public async Task> CreateProviderTool([FromBody] CreateProviderToolDto dto) + public Task CreateProviderTool([FromBody] CreateProviderToolDto dto) { - try - { - // Check if tool already exists for this provider - var existingTool = await _context.ProviderTools - .FirstOrDefaultAsync(pt => pt.Provider == dto.Provider && pt.ToolName == dto.ToolName); - - if (existingTool != null) + return ExecuteAsync( + async () => { - return Conflict(new { error = $"Tool '{dto.ToolName}' already exists for provider {dto.Provider}" }); - } + // Validate billing unit + ValidateBillingUnit(dto.BillingUnit); - var tool = new ProviderTool - { - Provider = dto.Provider, - ToolName = dto.ToolName, - ToolParameters = dto.ToolParameters, - CostPerUnit = dto.CostPerUnit, - BillingUnit = dto.BillingUnit, - CostDescription = dto.CostDescription, - IsActive = dto.IsActive, - UpdatedAt = DateTime.UtcNow - }; - - _context.ProviderTools.Add(tool); - await _context.SaveChangesAsync(); - - _logger.LogInformation("Created provider tool {ToolName} for {Provider}", tool.ToolName, tool.Provider); - - return CreatedAtAction( - nameof(GetProviderTool), - new { id = tool.Id }, - ProviderToolDto.FromEntity(tool)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating provider tool"); - return StatusCode(500, new { error = "Failed to create provider tool" }); - } + // Check if tool already exists for this provider + var existingTool = await _context.ProviderTools + .FirstOrDefaultAsync(pt => pt.Provider == dto.Provider && pt.ToolName == dto.ToolName); + + if (existingTool != null) + { + throw new InvalidOperationException($"Tool '{dto.ToolName}' already exists for provider {dto.Provider}"); + } + + var tool = new ProviderTool + { + Provider = dto.Provider, + ToolName = dto.ToolName, + ToolParameters = dto.ToolParameters, + CostPerUnit = dto.CostPerUnit, + BillingUnit = dto.BillingUnit, + CostDescription = dto.CostDescription, + IsActive = dto.IsActive, + UpdatedAt = DateTime.UtcNow + }; + + _context.ProviderTools.Add(tool); + await _context.SaveChangesAsync(); + + LogAdminAudit("Created", "ProviderTool", tool.Id, + $"ToolName: {LoggingSanitizer.S(tool.ToolName)}, Provider: {tool.Provider}"); + + await PublishToolChangedEventAsync(tool, "Created"); + + return ProviderToolDto.FromEntity(tool); + }, + result => CreatedAtAction(nameof(GetProviderTool), new { id = result.Id }, result), + "CreateProviderTool"); } /// @@ -145,35 +150,39 @@ public async Task> CreateProviderTool([FromBody] C /// Updated tool data /// Updated provider tool [HttpPut("{id}")] - public async Task> UpdateProviderTool(int id, [FromBody] UpdateProviderToolDto dto) + public Task UpdateProviderTool(int id, [FromBody] UpdateProviderToolDto dto) { - try - { - var tool = await _context.ProviderTools.FindAsync(id); - if (tool == null) + return ExecuteAsync( + async () => { - return NotFound(new { error = $"Provider tool with ID {id} not found" }); - } + // Validate billing unit + ValidateBillingUnit(dto.BillingUnit); - tool.IsActive = dto.IsActive; - tool.ToolParameters = dto.ToolParameters; - tool.CostPerUnit = dto.CostPerUnit; - tool.BillingUnit = dto.BillingUnit; - tool.CostDescription = dto.CostDescription; - tool.UpdatedAt = DateTime.UtcNow; + var tool = await _context.ProviderTools.FindAsync(id); + if (tool == null) + { + throw new KeyNotFoundException($"Provider tool with ID '{id}' not found"); + } - await _context.SaveChangesAsync(); + tool.IsActive = dto.IsActive; + tool.ToolParameters = dto.ToolParameters; + tool.CostPerUnit = dto.CostPerUnit; + tool.BillingUnit = dto.BillingUnit; + tool.CostDescription = dto.CostDescription; + tool.UpdatedAt = DateTime.UtcNow; - _logger.LogInformation("Updated provider tool {Id} ({ToolName} for {Provider})", - id, tool.ToolName, tool.Provider); + await _context.SaveChangesAsync(); - return Ok(ProviderToolDto.FromEntity(tool)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating provider tool {Id}", id); - return StatusCode(500, new { error = "Failed to update provider tool" }); - } + LogAdminAudit("Updated", "ProviderTool", id, + $"ToolName: {LoggingSanitizer.S(tool.ToolName)}, Provider: {tool.Provider}"); + + await PublishToolChangedEventAsync(tool, "Updated"); + + return ProviderToolDto.FromEntity(tool); + }, + result => Ok(result), + "UpdateProviderTool", + new { Id = id }); } /// @@ -182,29 +191,28 @@ public async Task> UpdateProviderTool(int id, [Fro /// Tool ID /// Success status [HttpDelete("{id}")] - public async Task DeleteProviderTool(int id) + public Task DeleteProviderTool(int id) { - try - { - var tool = await _context.ProviderTools.FindAsync(id); - if (tool == null) + return ExecuteAsync( + async () => { - return NotFound(new { error = $"Provider tool with ID {id} not found" }); - } + var tool = await _context.ProviderTools.FindAsync(id); + if (tool == null) + { + throw new KeyNotFoundException($"Provider tool with ID '{id}' not found"); + } - _context.ProviderTools.Remove(tool); - await _context.SaveChangesAsync(); + _context.ProviderTools.Remove(tool); + await _context.SaveChangesAsync(); - _logger.LogInformation("Deleted provider tool {Id} ({ToolName} for {Provider})", - id, tool.ToolName, tool.Provider); + LogAdminAudit("Deleted", "ProviderTool", id, + $"ToolName: {LoggingSanitizer.S(tool.ToolName)}, Provider: {tool.Provider}"); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting provider tool {Id}", id); - return StatusCode(500, new { error = "Failed to delete provider tool" }); - } + await PublishToolChangedEventAsync(tool, "Deleted"); + }, + NoContent(), + "DeleteProviderTool", + new { Id = id }); } /// @@ -233,18 +241,7 @@ public ActionResult> GetToolProviders() [HttpGet("billing-units")] public ActionResult> GetBillingUnits() { - var billingUnits = new[] - { - "requests", - "hours", - "minutes", - "searches", - "executions", - "characters", - "tokens" - }; - - return Ok(billingUnits); + return Ok(ProviderToolBillingUnits.All); } /// @@ -253,96 +250,172 @@ public ActionResult> GetBillingUnits() /// Array of provider tools to import /// Import results [HttpPost("import")] - public async Task> ImportProviderTools([FromBody] List tools) + public Task ImportProviderTools([FromBody] List tools) { - try - { - var imported = 0; - var skipped = 0; - var errors = new List(); - - foreach (var dto in tools) + return ExecuteAsync( + async () => { - try - { - // Check if tool already exists - var exists = await _context.ProviderTools - .AnyAsync(pt => pt.Provider == dto.Provider && pt.ToolName == dto.ToolName); + var imported = 0; + var skipped = 0; + var errors = new List(); + var affectedProviders = new HashSet(); - if (exists) + foreach (var dto in tools) + { + try { - skipped++; - errors.Add($"Tool '{dto.ToolName}' already exists for {dto.Provider}"); - continue; + // Validate billing unit + if (!ProviderToolBillingUnits.IsValid(dto.BillingUnit)) + { + errors.Add($"Tool '{dto.ToolName}': Invalid billing unit '{dto.BillingUnit}'. " + + $"Must be one of: {string.Join(", ", ProviderToolBillingUnits.All)}"); + skipped++; + continue; + } + + // Check if tool already exists + var exists = await _context.ProviderTools + .AnyAsync(pt => pt.Provider == dto.Provider && pt.ToolName == dto.ToolName); + + if (exists) + { + skipped++; + errors.Add($"Tool '{dto.ToolName}' already exists for {dto.Provider}"); + continue; + } + + var tool = new ProviderTool + { + Provider = dto.Provider, + ToolName = dto.ToolName, + ToolParameters = dto.ToolParameters, + CostPerUnit = dto.CostPerUnit, + BillingUnit = dto.BillingUnit, + CostDescription = dto.CostDescription, + IsActive = dto.IsActive, + UpdatedAt = DateTime.UtcNow + }; + + _context.ProviderTools.Add(tool); + imported++; + affectedProviders.Add(dto.Provider); } - - var tool = new ProviderTool + catch (Exception ex) { - Provider = dto.Provider, - ToolName = dto.ToolName, - ToolParameters = dto.ToolParameters, - CostPerUnit = dto.CostPerUnit, - BillingUnit = dto.BillingUnit, - CostDescription = dto.CostDescription, - IsActive = dto.IsActive, - UpdatedAt = DateTime.UtcNow - }; - - _context.ProviderTools.Add(tool); - imported++; + errors.Add($"Failed to import {dto.ToolName}: {ex.Message}"); + } } - catch (Exception ex) + + if (imported > 0) { - errors.Add($"Failed to import {dto.ToolName}: {ex.Message}"); + await _context.SaveChangesAsync(); + + // Publish events for each affected provider + foreach (var provider in affectedProviders) + { + await PublishToolChangedEventAsync(provider, "BulkImport"); + } } - } - if (imported > 0) + LogAdminAudit("Imported", "ProviderTool", + detail: $"Imported: {imported}, Skipped: {skipped}, Total: {tools.Count}"); + + return new + { + imported, + skipped, + total = tools.Count, + errors = errors.Count > 0 ? errors : null + }; + }, + result => Ok(result), + "ImportProviderTools"); + } + + /// + /// Exports all provider tools as JSON. + /// + /// JSON array of all provider tools + [HttpGet("export")] + public Task ExportProviderTools() + { + return ExecuteAsync( + async () => { - await _context.SaveChangesAsync(); - } + var tools = await _context.ProviderTools + .OrderBy(pt => pt.Provider) + .ThenBy(pt => pt.ToolName) + .ToListAsync(); + + var dtos = tools.Select(ProviderToolDto.FromEntity); + + Response.Headers.Append("Content-Disposition", "attachment; filename=provider-tools.json"); + return dtos; + }, + result => Ok(result), + "ExportProviderTools"); + } - _logger.LogInformation("Imported {Imported} provider tools, skipped {Skipped}", imported, skipped); + /// + /// Validates that the billing unit is a recognized value. + /// + private static void ValidateBillingUnit(string? billingUnit) + { + if (!ProviderToolBillingUnits.IsValid(billingUnit)) + { + throw new ArgumentException( + $"Invalid billing unit '{billingUnit}'. Must be one of: {string.Join(", ", ProviderToolBillingUnits.All)}"); + } + } - return Ok(new + /// + /// Publishes a ProviderToolChanged event for cache invalidation. + /// + private async Task PublishToolChangedEventAsync(ProviderTool tool, string changeType) + { + if (_publishEndpoint == null) return; + + try + { + await _publishEndpoint.Publish(new ProviderToolChanged { - imported, - skipped, - total = tools.Count, - errors = errors.Any() ? errors : null + ProviderToolId = tool.Id, + ToolName = tool.ToolName, + ProviderType = tool.Provider?.ToString() ?? "Unknown", + ChangeType = changeType, + CorrelationId = Guid.NewGuid().ToString() }); } catch (Exception ex) { - _logger.LogError(ex, "Error importing provider tools"); - return StatusCode(500, new { error = "Failed to import provider tools" }); + Logger.LogWarning(ex, "Failed to publish ProviderToolChanged event for {ToolName} โ€” operation completed but cache may be stale", + tool.ToolName); } } /// - /// Exports all provider tools as JSON. + /// Publishes a ProviderToolChanged event for a provider type (bulk operations). /// - /// JSON array of all provider tools - [HttpGet("export")] - public async Task>> ExportProviderTools() + private async Task PublishToolChangedEventAsync(ProviderType providerType, string changeType) { + if (_publishEndpoint == null) return; + try { - var tools = await _context.ProviderTools - .OrderBy(pt => pt.Provider) - .ThenBy(pt => pt.ToolName) - .ToListAsync(); - - var dtos = tools.Select(ProviderToolDto.FromEntity); - - Response.Headers.Append("Content-Disposition", "attachment; filename=provider-tools.json"); - return Ok(dtos); + await _publishEndpoint.Publish(new ProviderToolChanged + { + ProviderToolId = 0, + ToolName = "*", + ProviderType = providerType.ToString(), + ChangeType = changeType, + CorrelationId = Guid.NewGuid().ToString() + }); } catch (Exception ex) { - _logger.LogError(ex, "Error exporting provider tools"); - return StatusCode(500, new { error = "Failed to export provider tools" }); + Logger.LogWarning(ex, "Failed to publish ProviderToolChanged event for {ProviderType} โ€” operation completed but cache may be stale", + providerType); } } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Controllers/SecurityMonitoringController.cs b/Services/ConduitLLM.Admin/Controllers/SecurityMonitoringController.cs index 782c01d5c..91bdb2ec5 100644 --- a/Services/ConduitLLM.Admin/Controllers/SecurityMonitoringController.cs +++ b/Services/ConduitLLM.Admin/Controllers/SecurityMonitoringController.cs @@ -13,10 +13,9 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("api/security")] [Authorize(Policy = "MasterKeyPolicy")] - public class SecurityMonitoringController : ControllerBase + public class SecurityMonitoringController : AdminControllerBase { private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; private readonly IMemoryCache _cache; /// @@ -29,9 +28,9 @@ public SecurityMonitoringController( IDbContextFactory dbContextFactory, ILogger logger, IMemoryCache cache) + : base(logger) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); } @@ -42,113 +41,111 @@ public SecurityMonitoringController( /// Cancellation token. /// Security events data. [HttpGet("events")] - public async Task GetSecurityEvents( + public Task GetSecurityEvents( [FromQuery] int hours = 24, CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var startTime = DateTime.UtcNow.AddHours(-hours); - - // Get authentication failures (401 status codes) - var authFailures = await dbContext.RequestLogs - .Where(r => r.Timestamp >= startTime && r.StatusCode == 401) - .Select(r => new - { - Timestamp = r.Timestamp, - Type = "auth_failure", - Severity = "warning", - Source = r.ClientIp ?? "Unknown", - VirtualKeyId = r.VirtualKeyId.ToString(), - Details = "Unauthorized access attempt", - StatusCode = r.StatusCode - }) - .ToListAsync(cancellationToken); - - // Get rate limit violations (429 status codes) - var rateLimitViolations = await dbContext.RequestLogs - .Where(r => r.Timestamp >= startTime && r.StatusCode == 429) - .Select(r => new - { - Timestamp = r.Timestamp, - Type = "rate_limit", - Severity = "warning", - Source = r.ClientIp ?? "Unknown", - VirtualKeyId = r.VirtualKeyId.ToString(), - Details = "Rate limit exceeded", - StatusCode = r.StatusCode - }) - .ToListAsync(cancellationToken); - - // Get blocked IP attempts - var blockedIps = await dbContext.IpFilters - .Where(f => f.FilterType == "blacklist" && f.IsEnabled) - .Join(dbContext.RequestLogs.Where(r => r.Timestamp >= startTime), - f => f.IpAddressOrCidr, - r => r.ClientIp, - (f, r) => new + return ExecuteAsync( + async () => + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var startTime = DateTime.UtcNow.AddHours(-hours); + + // Get authentication failures (401 status codes) + var authFailures = await dbContext.RequestLogs + .Where(r => r.Timestamp >= startTime && r.StatusCode == 401) + .Select(r => new { Timestamp = r.Timestamp, - Type = "blocked_ip", - Severity = "high", + Type = "auth_failure", + Severity = "warning", Source = r.ClientIp ?? "Unknown", VirtualKeyId = r.VirtualKeyId.ToString(), - Details = $"Blocked by rule: {f.Description ?? "IP Filter"}", - StatusCode = 403 + Details = "Unauthorized access attempt", + StatusCode = r.StatusCode }) - .ToListAsync(cancellationToken); - - // Get suspicious activity (multiple failed attempts from same IP) - var suspiciousActivity = await dbContext.RequestLogs - .Where(r => r.Timestamp >= startTime && r.StatusCode >= 400 && r.ClientIp != null) - .GroupBy(r => r.ClientIp) - .Where(g => g.Count() >= 5) - .Select(g => new - { - Timestamp = g.Max(r => r.Timestamp), - Type = "suspicious_activity", - Severity = "high", - Source = g.Key ?? "Unknown", - VirtualKeyId = (string?)null!, // null-forgiving operator added to suppress CS8600 - Details = $"Multiple failed requests: {g.Count()} attempts", - StatusCode = 0 - }) - .ToListAsync(cancellationToken); - - // Combine all events - cast to common base type - var allEvents = authFailures.Cast() - .Concat(rateLimitViolations.Cast()) - .Concat(blockedIps.Cast()) - .Concat(suspiciousActivity.Cast()) - .OrderByDescending(e => e.Timestamp) - .Take(1000) - .ToList(); - - return Ok(new - { - Timestamp = DateTime.UtcNow, - TimeRange = new { Start = startTime, End = DateTime.UtcNow }, - TotalEvents = allEvents.Count, - EventsByType = allEvents.GroupBy(e => (string)e.Type).Select(g => new - { - Type = g.Key, - Count = g.Count() - }), - EventsBySeverity = allEvents.GroupBy(e => (string)e.Severity).Select(g => new + .ToListAsync(cancellationToken); + + // Get rate limit violations (429 status codes) + var rateLimitViolations = await dbContext.RequestLogs + .Where(r => r.Timestamp >= startTime && r.StatusCode == 429) + .Select(r => new + { + Timestamp = r.Timestamp, + Type = "rate_limit", + Severity = "warning", + Source = r.ClientIp ?? "Unknown", + VirtualKeyId = r.VirtualKeyId.ToString(), + Details = "Rate limit exceeded", + StatusCode = r.StatusCode + }) + .ToListAsync(cancellationToken); + + // Get blocked IP attempts + var blockedIps = await dbContext.IpFilters + .Where(f => f.FilterType == "blacklist" && f.IsEnabled) + .Join(dbContext.RequestLogs.Where(r => r.Timestamp >= startTime), + f => f.IpAddressOrCidr, + r => r.ClientIp, + (f, r) => new + { + Timestamp = r.Timestamp, + Type = "blocked_ip", + Severity = "high", + Source = r.ClientIp ?? "Unknown", + VirtualKeyId = r.VirtualKeyId.ToString(), + Details = $"Blocked by rule: {f.Description ?? "IP Filter"}", + StatusCode = 403 + }) + .ToListAsync(cancellationToken); + + // Get suspicious activity (multiple failed attempts from same IP) + var suspiciousActivity = await dbContext.RequestLogs + .Where(r => r.Timestamp >= startTime && r.StatusCode >= 400 && r.ClientIp != null) + .GroupBy(r => r.ClientIp) + .Where(g => g.Count() >= 5) + .Select(g => new + { + Timestamp = g.Max(r => r.Timestamp), + Type = "suspicious_activity", + Severity = "high", + Source = g.Key ?? "Unknown", + VirtualKeyId = (string?)null!, // null-forgiving operator added to suppress CS8600 + Details = $"Multiple failed requests: {g.Count()} attempts", + StatusCode = 0 + }) + .ToListAsync(cancellationToken); + + // Combine all events - cast to common base type + var allEvents = authFailures.Cast() + .Concat(rateLimitViolations.Cast()) + .Concat(blockedIps.Cast()) + .Concat(suspiciousActivity.Cast()) + .OrderByDescending(e => e.Timestamp) + .Take(1000) + .ToList(); + + return new { - Severity = g.Key, - Count = g.Count() - }), - Events = allEvents - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve security events"); - return StatusCode(500, new { error = "Failed to retrieve security events", message = ex.Message }); - } + Timestamp = DateTime.UtcNow, + TimeRange = new { Start = startTime, End = DateTime.UtcNow }, + TotalEvents = allEvents.Count, + EventsByType = allEvents.GroupBy(e => (string)e.Type).Select(g => new + { + Type = g.Key, + Count = g.Count() + }), + EventsBySeverity = allEvents.GroupBy(e => (string)e.Severity).Select(g => new + { + Severity = g.Key, + Count = g.Count() + }), + Events = allEvents + }; + }, + Ok, + "GetSecurityEvents"); } /// @@ -157,106 +154,104 @@ public async Task GetSecurityEvents( /// Cancellation token. /// Threat analytics information. [HttpGet("threats")] - public async Task GetThreatAnalytics(CancellationToken cancellationToken = default) + public Task GetThreatAnalytics(CancellationToken cancellationToken = default) { - try - { - var cacheKey = "security:threats"; - if (_cache.TryGetValue(cacheKey, out var cachedData)) + return ExecuteAsync( + async () => { - return Ok(cachedData); - } - - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var now = DateTime.UtcNow; - var oneDayAgo = now.AddDays(-1); - var oneWeekAgo = now.AddDays(-7); - - // Analyze threat patterns - var threatPatterns = await dbContext.RequestLogs - .Where(r => r.Timestamp >= oneWeekAgo && r.StatusCode >= 400 && r.ClientIp != null) - .GroupBy(r => new { r.ClientIp, Date = r.Timestamp.Date }) - .Select(g => new + var cacheKey = "security:threats"; + if (_cache.TryGetValue(cacheKey, out var cachedData) && cachedData != null) { - ClientIp = g.Key.ClientIp, - Date = g.Key.Date, - FailedAttempts = g.Count(), - ErrorTypes = g.Select(r => r.StatusCode).Distinct().Count() - }) - .ToListAsync(cancellationToken); - - // Get top threat sources - var topThreats = threatPatterns - .GroupBy(t => t.ClientIp) - .Select(g => new - { - IpAddress = g.Key, - TotalFailures = g.Sum(t => t.FailedAttempts), - DaysActive = g.Select(t => t.Date).Distinct().Count(), - LastSeen = g.Max(t => t.Date), - RiskScore = CalculateRiskScore(g.Sum(t => t.FailedAttempts), g.Count()) - }) - .OrderByDescending(t => t.RiskScore) - .Take(20) - .ToList(); - - // Get threat distribution by type - var threatDistribution = await dbContext.RequestLogs - .Where(r => r.Timestamp >= oneDayAgo && r.StatusCode >= 400) - .GroupBy(r => GetThreatTypeByStatusCode(r.StatusCode ?? 0)) - .Select(g => new - { - Type = g.Key, - Count = g.Count(), - UniqueIPs = g.Where(r => r.ClientIp != null).Select(r => r.ClientIp).Distinct().Count() - }) - .ToListAsync(cancellationToken); - - // Calculate security metrics - var securityMetrics = new - { - TotalThreatsToday = await dbContext.RequestLogs - .CountAsync(r => r.Timestamp >= DateTime.UtcNow.Date && r.StatusCode >= 400, cancellationToken), - UniqueThreatsToday = await dbContext.RequestLogs - .Where(r => r.Timestamp >= DateTime.UtcNow.Date && r.StatusCode >= 400 && r.ClientIp != null) - .Select(r => r.ClientIp) - .Distinct() - .CountAsync(cancellationToken), - BlockedIPs = await dbContext.IpFilters.CountAsync(f => f.FilterType == "blacklist", cancellationToken), - ComplianceScore = 85.0 // Simplified compliance score - }; - - // Get threat trend - var threatTrend = threatPatterns - .GroupBy(t => t.Date) - .Select(g => new + return cachedData; + } + + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var now = DateTime.UtcNow; + var oneDayAgo = now.AddDays(-1); + var oneWeekAgo = now.AddDays(-7); + + // Analyze threat patterns + var threatPatterns = await dbContext.RequestLogs + .Where(r => r.Timestamp >= oneWeekAgo && r.StatusCode >= 400 && r.ClientIp != null) + .GroupBy(r => new { r.ClientIp, Date = r.Timestamp.Date }) + .Select(g => new + { + ClientIp = g.Key.ClientIp, + Date = g.Key.Date, + FailedAttempts = g.Count(), + ErrorTypes = g.Select(r => r.StatusCode).Distinct().Count() + }) + .ToListAsync(cancellationToken); + + // Get top threat sources + var topThreats = threatPatterns + .GroupBy(t => t.ClientIp) + .Select(g => new + { + IpAddress = g.Key, + TotalFailures = g.Sum(t => t.FailedAttempts), + DaysActive = g.Select(t => t.Date).Distinct().Count(), + LastSeen = g.Max(t => t.Date), + RiskScore = CalculateRiskScore(g.Sum(t => t.FailedAttempts), g.Count()) + }) + .OrderByDescending(t => t.RiskScore) + .Take(20) + .ToList(); + + // Get threat distribution by type + var threatDistribution = await dbContext.RequestLogs + .Where(r => r.Timestamp >= oneDayAgo && r.StatusCode >= 400) + .GroupBy(r => GetThreatTypeByStatusCode(r.StatusCode ?? 0)) + .Select(g => new + { + Type = g.Key, + Count = g.Count(), + UniqueIPs = g.Where(r => r.ClientIp != null).Select(r => r.ClientIp).Distinct().Count() + }) + .ToListAsync(cancellationToken); + + // Calculate security metrics + var securityMetrics = new { - Date = g.Key, - Threats = g.Sum(t => t.FailedAttempts) - }) - .OrderBy(t => t.Date) - .ToList(); + TotalThreatsToday = await dbContext.RequestLogs + .CountAsync(r => r.Timestamp >= DateTime.UtcNow.Date && r.StatusCode >= 400, cancellationToken), + UniqueThreatsToday = await dbContext.RequestLogs + .Where(r => r.Timestamp >= DateTime.UtcNow.Date && r.StatusCode >= 400 && r.ClientIp != null) + .Select(r => r.ClientIp) + .Distinct() + .CountAsync(cancellationToken), + BlockedIPs = await dbContext.IpFilters.CountAsync(f => f.FilterType == "blacklist", cancellationToken), + ComplianceScore = 85.0 // Simplified compliance score + }; + + // Get threat trend + var threatTrend = threatPatterns + .GroupBy(t => t.Date) + .Select(g => new + { + Date = g.Key, + Threats = g.Sum(t => t.FailedAttempts) + }) + .OrderBy(t => t.Date) + .ToList(); - var result = new - { - Timestamp = now, - Metrics = securityMetrics, - TopThreats = topThreats, - ThreatDistribution = threatDistribution, - ThreatTrend = threatTrend - }; - - // Cache for 5 minutes - _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5)); - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve threat analytics"); - return StatusCode(500, new { error = "Failed to retrieve threat analytics", message = ex.Message }); - } + var result = new + { + Timestamp = now, + Metrics = securityMetrics, + TopThreats = topThreats, + ThreatDistribution = threatDistribution, + ThreatTrend = threatTrend + }; + + // Cache for 5 minutes + _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5)); + + return (object)result; + }, + Ok, + "GetThreatAnalytics"); } /// @@ -265,46 +260,44 @@ public async Task GetThreatAnalytics(CancellationToken cancellati /// Cancellation token. /// Compliance information. [HttpGet("compliance")] - public async Task GetComplianceMetrics(CancellationToken cancellationToken = default) + public Task GetComplianceMetrics(CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var complianceData = new + return ExecuteAsync( + async () => { - Timestamp = DateTime.UtcNow, - DataProtection = new - { - EncryptedKeys = await dbContext.VirtualKeys.CountAsync(k => k.IsEnabled, cancellationToken), - SecureEndpoints = true, // Assuming HTTPS is enforced - DataRetentionDays = 90, - LastAudit = DateTime.UtcNow.AddDays(-7) - }, - AccessControl = new - { - ActiveKeys = await dbContext.VirtualKeys.CountAsync(k => k.IsEnabled, cancellationToken), - KeysWithBudgets = await dbContext.VirtualKeyGroups.CountAsync(g => g.Balance > 0, cancellationToken), - IpWhitelistEnabled = await dbContext.IpFilters.AnyAsync(f => f.FilterType == "whitelist", cancellationToken), - RateLimitingEnabled = true - }, - Monitoring = new + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var complianceData = new { - LogRetentionDays = 90, - RequestLoggingEnabled = true, - SecurityAlertsEnabled = true, - LastSecurityReview = DateTime.UtcNow.AddDays(-30) - }, - ComplianceScore = await CalculateDetailedComplianceScore(dbContext, cancellationToken) - }; - - return Ok(complianceData); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve compliance metrics"); - return StatusCode(500, new { error = "Failed to retrieve compliance metrics", message = ex.Message }); - } + Timestamp = DateTime.UtcNow, + DataProtection = new + { + EncryptedKeys = await dbContext.VirtualKeys.CountAsync(k => k.IsEnabled, cancellationToken), + SecureEndpoints = true, // Assuming HTTPS is enforced + DataRetentionDays = 90, + LastAudit = DateTime.UtcNow.AddDays(-7) + }, + AccessControl = new + { + ActiveKeys = await dbContext.VirtualKeys.CountAsync(k => k.IsEnabled, cancellationToken), + KeysWithBudgets = await dbContext.VirtualKeyGroups.CountAsync(g => g.Balance > 0, cancellationToken), + IpWhitelistEnabled = await dbContext.IpFilters.AnyAsync(f => f.FilterType == "whitelist", cancellationToken), + RateLimitingEnabled = true + }, + Monitoring = new + { + LogRetentionDays = 90, + RequestLoggingEnabled = true, + SecurityAlertsEnabled = true, + LastSecurityReview = DateTime.UtcNow.AddDays(-30) + }, + ComplianceScore = await CalculateDetailedComplianceScore(dbContext, cancellationToken) + }; + + return complianceData; + }, + Ok, + "GetComplianceMetrics"); } private static string GetThreatTypeByStatusCode(int statusCode) diff --git a/Services/ConduitLLM.Admin/Controllers/SystemInfoController.cs b/Services/ConduitLLM.Admin/Controllers/SystemInfoController.cs index 7512eccb3..3a509b938 100644 --- a/Services/ConduitLLM.Admin/Controllers/SystemInfoController.cs +++ b/Services/ConduitLLM.Admin/Controllers/SystemInfoController.cs @@ -14,11 +14,10 @@ namespace ConduitLLM.Admin.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "MasterKeyPolicy")] -public class SystemInfoController : ControllerBase +public class SystemInfoController : AdminControllerBase { private readonly IAdminSystemInfoService _systemInfoService; private readonly IPublishEndpoint _publishEndpoint; - private readonly ILogger _logger; private readonly IFunctionDiscoveryCacheService? _functionDiscoveryCacheService; /// @@ -33,10 +32,10 @@ public SystemInfoController( IPublishEndpoint publishEndpoint, ILogger logger, IFunctionDiscoveryCacheService? functionDiscoveryCacheService = null) + : base(publishEndpoint, logger) { _systemInfoService = systemInfoService ?? throw new ArgumentNullException(nameof(systemInfoService)); _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _functionDiscoveryCacheService = functionDiscoveryCacheService; } @@ -47,18 +46,12 @@ public SystemInfoController( [HttpGet("info")] [ProducesResponseType(typeof(SystemInfoDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetSystemInfo() + public Task GetSystemInfo() { - try - { - var systemInfo = await _systemInfoService.GetSystemInfoAsync(); - return Ok(systemInfo); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting system information"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _systemInfoService.GetSystemInfoAsync(), + result => Ok(result), + "GetSystemInfo"); } /// @@ -68,18 +61,12 @@ public async Task GetSystemInfo() [HttpGet("health")] [ProducesResponseType(typeof(HealthStatusDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetHealthStatus() + public Task GetHealthStatus() { - try - { - var healthStatus = await _systemInfoService.GetHealthStatusAsync(); - return Ok(healthStatus); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting health status"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _systemInfoService.GetHealthStatusAsync(), + result => Ok(result), + "GetHealthStatus"); } /// @@ -89,36 +76,30 @@ public async Task GetHealthStatus() [HttpPost("cache/invalidate-discovery")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task InvalidateDiscoveryCache() + public Task InvalidateDiscoveryCache() { - try - { - // Publish event to all Gateway API instances via MassTransit - await _publishEndpoint.Publish(new DiscoveryCacheInvalidationRequested + return ExecuteAsync( + async () => { - Reason = "Manual invalidation via Admin API", - RequestedBy = "Admin User", - CorrelationId = Guid.NewGuid().ToString() - }); + // Publish event to all Gateway API instances via MassTransit + await _publishEndpoint.Publish(new DiscoveryCacheInvalidationRequested + { + Reason = "Manual invalidation via Admin API", + RequestedBy = "Admin User", + CorrelationId = Guid.NewGuid().ToString() + }); - _logger.LogInformation("Published discovery cache invalidation event to all Gateway API instances"); + LogAdminAudit("Invalidated", "DiscoveryCache"); - return Ok(new - { - message = "Discovery cache invalidation request published successfully", - timestamp = DateTime.UtcNow, - note = "Cache invalidation is being processed asynchronously across all Gateway API instances" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error publishing discovery cache invalidation event"); - return StatusCode(StatusCodes.Status500InternalServerError, new - { - message = "An error occurred while requesting discovery cache invalidation", - error = ex.Message - }); - } + return new + { + message = "Discovery cache invalidation request published successfully", + timestamp = DateTime.UtcNow, + note = "Cache invalidation is being processed asynchronously across all Gateway API instances" + }; + }, + result => Ok(result), + "InvalidateDiscoveryCache"); } /// @@ -129,31 +110,21 @@ await _publishEndpoint.Publish(new DiscoveryCacheInvalidationRequested [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetFunctionDiscoveryCacheStats() + public Task GetFunctionDiscoveryCacheStats() { - try + if (_functionDiscoveryCacheService == null) { - if (_functionDiscoveryCacheService == null) + return Task.FromResult(NotFound(new { - return NotFound(new - { - message = "Function discovery cache service is not configured", - note = "The cache service must be registered in the DI container" - }); - } - - var stats = await _functionDiscoveryCacheService.GetStatisticsAsync(); - return Ok(stats); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function discovery cache statistics"); - return StatusCode(StatusCodes.Status500InternalServerError, new - { - message = "An error occurred while retrieving cache statistics", - error = ex.Message - }); + message = "Function discovery cache service is not configured", + note = "The cache service must be registered in the DI container" + })); } + + return ExecuteAsync( + () => _functionDiscoveryCacheService.GetStatisticsAsync(), + result => Ok(result), + "GetFunctionDiscoveryCacheStats"); } /// @@ -163,35 +134,29 @@ public async Task GetFunctionDiscoveryCacheStats() [HttpPost("cache/invalidate-function-discovery")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task InvalidateFunctionDiscoveryCache() + public Task InvalidateFunctionDiscoveryCache() { - try - { - // Publish event to all Gateway API instances via MassTransit - await _publishEndpoint.Publish(new FunctionDiscoveryCacheInvalidationRequested + return ExecuteAsync( + async () => { - Reason = "Manual invalidation via Admin API", - RequestedBy = "Admin User", - CorrelationId = Guid.NewGuid().ToString() - }); + // Publish event to all Gateway API instances via MassTransit + await _publishEndpoint.Publish(new FunctionDiscoveryCacheInvalidationRequested + { + Reason = "Manual invalidation via Admin API", + RequestedBy = "Admin User", + CorrelationId = Guid.NewGuid().ToString() + }); - _logger.LogInformation("Published function discovery cache invalidation event to all Gateway API instances"); + LogAdminAudit("Invalidated", "FunctionDiscoveryCache"); - return Ok(new - { - message = "Function discovery cache invalidation request published successfully", - timestamp = DateTime.UtcNow, - note = "Cache invalidation is being processed asynchronously across all Gateway API instances" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error publishing function discovery cache invalidation event"); - return StatusCode(StatusCodes.Status500InternalServerError, new - { - message = "An error occurred while requesting function discovery cache invalidation", - error = ex.Message - }); - } + return new + { + message = "Function discovery cache invalidation request published successfully", + timestamp = DateTime.UtcNow, + note = "Cache invalidation is being processed asynchronously across all Gateway API instances" + }; + }, + result => Ok(result), + "InvalidateFunctionDiscoveryCache"); } } diff --git a/Services/ConduitLLM.Admin/Controllers/TasksController.cs b/Services/ConduitLLM.Admin/Controllers/TasksController.cs index a403db017..e9c289a2d 100644 --- a/Services/ConduitLLM.Admin/Controllers/TasksController.cs +++ b/Services/ConduitLLM.Admin/Controllers/TasksController.cs @@ -10,10 +10,9 @@ namespace ConduitLLM.Admin.Controllers [ApiController] [Route("v1/admin/tasks")] [Authorize(Policy = "MasterKeyPolicy")] - public class TasksController : ControllerBase + public class TasksController : AdminControllerBase { private readonly IAsyncTaskService _taskService; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -21,9 +20,9 @@ public class TasksController : ControllerBase /// The async task service. /// The logger. public TasksController(IAsyncTaskService taskService, ILogger logger) + : base(logger) { _taskService = taskService ?? throw new ArgumentNullException(nameof(taskService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -37,23 +36,21 @@ public TasksController(IAsyncTaskService taskService, ILogger l /// permanently deletes archived tasks older than 30 days. /// [HttpPost("cleanup")] - public async Task CleanupOldTasks([FromQuery] int olderThanHours = 24) + public Task CleanupOldTasks([FromQuery] int olderThanHours = 24) { - try - { - olderThanHours = Math.Max(olderThanHours, 1); // Min 1 hour - var count = await _taskService.CleanupOldTasksAsync(TimeSpan.FromHours(olderThanHours)); - - _logger.LogInformation("Admin cleaned up {Count} old tasks (older than {Hours} hours)", - count, olderThanHours); - - return Ok(new { cleaned_up = count, older_than_hours = olderThanHours }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error cleaning up old tasks"); - return StatusCode(500, new { error = new { message = "An error occurred while cleaning up tasks", type = "server_error" } }); - } + return ExecuteAsync( + async () => + { + olderThanHours = Math.Max(olderThanHours, 1); // Min 1 hour + var count = await _taskService.CleanupOldTasksAsync(TimeSpan.FromHours(olderThanHours)); + + LogAdminAudit("CleanedUp", "Tasks", null, + $"Removed {count} tasks older than {olderThanHours} hours"); + + return new { cleaned_up = count, older_than_hours = olderThanHours }; + }, + Ok, + "CleanupOldTasks"); } } } diff --git a/Services/ConduitLLM.Admin/Controllers/VirtualKeyGroupsController.cs b/Services/ConduitLLM.Admin/Controllers/VirtualKeyGroupsController.cs index a2f0fc860..cf598470c 100644 --- a/Services/ConduitLLM.Admin/Controllers/VirtualKeyGroupsController.cs +++ b/Services/ConduitLLM.Admin/Controllers/VirtualKeyGroupsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; @@ -16,13 +17,12 @@ namespace ConduitLLM.Admin.Controllers [Authorize] [ApiController] [Route("api/[controller]")] - public class VirtualKeyGroupsController : ControllerBase + public class VirtualKeyGroupsController : AdminControllerBase { private readonly IVirtualKeyGroupRepository _groupRepository; private readonly IVirtualKeyRepository _keyRepository; private readonly IConfigurationDbContext _context; private readonly IRefundService _refundService; - private readonly ILogger _logger; /// /// Initializes a new instance of the VirtualKeyGroupsController @@ -33,31 +33,39 @@ public VirtualKeyGroupsController( IConfigurationDbContext context, IRefundService refundService, ILogger logger) + : base(logger) { _groupRepository = groupRepository; _keyRepository = keyRepository; _context = context; _refundService = refundService; - _logger = logger; } /// - /// Get all virtual key groups + /// Get all virtual key groups with pagination /// + /// Page number (1-based, default: 1) + /// Number of items per page (default: 50, max: 100) + /// Cancellation token [HttpGet] - public async Task>> GetAllGroups() + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public Task GetAllGroups( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + CancellationToken cancellationToken = default) { - try - { - _logger.LogInformation("GetAllGroups called"); - var groups = await _groupRepository.GetAllAsync(); - _logger.LogInformation("Repository returned {Count} groups", groups.Count()); - var dtos = groups.Select(g => + // Validate and clamp page parameters + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > 100) pageSize = 100; + + return ExecuteAsync( + async () => { - _logger.LogInformation("Group {GroupId} has {KeyCount} keys (null: {IsNull})", - g.Id, g.VirtualKeys?.Count ?? -1, g.VirtualKeys == null); - - return new VirtualKeyGroupDto + var (groups, totalCount) = await _groupRepository.GetPaginatedAsync(page, pageSize, cancellationToken); + + var dtos = groups.Select(g => new VirtualKeyGroupDto { Id = g.Id, ExternalGroupId = g.ExternalGroupId, @@ -68,33 +76,30 @@ public async Task>> GetAllGroups() CreatedAt = g.CreatedAt, UpdatedAt = g.UpdatedAt, VirtualKeyCount = g.VirtualKeys?.Count ?? 0 - }; - }).ToList(); + }).ToList(); - return Ok(dtos); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving virtual key groups"); - return StatusCode(500, new { message = "An error occurred while retrieving groups" }); - } + return (object)new PagedResult + { + Items = dtos, + TotalCount = totalCount, + CurrentPage = page, + PageSize = pageSize, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + }, + Ok, + "GetAllGroups"); } /// /// Get a specific virtual key group by ID /// [HttpGet("{id}")] - public async Task> GetGroup(int id) + public Task GetGroup(int id) { - try - { - var group = await _groupRepository.GetByIdWithKeysAsync(id); - if (group == null) - { - return NotFound(new { message = "Group not found" }); - } - - var dto = new VirtualKeyGroupDto + return ExecuteWithNotFoundAsync( + () => _groupRepository.GetByIdWithKeysAsync(id), + group => Ok(new VirtualKeyGroupDto { Id = group.Id, ExternalGroupId = group.ExternalGroupId, @@ -105,171 +110,163 @@ public async Task> GetGroup(int id) CreatedAt = group.CreatedAt, UpdatedAt = group.UpdatedAt, VirtualKeyCount = group.VirtualKeys?.Count ?? 0 - }; - - return Ok(dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving virtual key group {GroupId}", id); - return StatusCode(500, new { message = "An error occurred while retrieving the group" }); - } + }), + "VirtualKeyGroup", + id, + "GetGroup"); } /// /// Create a new virtual key group /// [HttpPost] - public async Task> CreateGroup([FromBody] CreateVirtualKeyGroupRequestDto request) + public Task CreateGroup([FromBody] CreateVirtualKeyGroupRequestDto request) { - try - { - var group = new VirtualKeyGroup + return ExecuteAsync( + async () => { - ExternalGroupId = request.ExternalGroupId, - GroupName = request.GroupName, - Balance = request.InitialBalance ?? 0, - LifetimeCreditsAdded = request.InitialBalance ?? 0, - LifetimeSpent = 0 - }; + var group = new VirtualKeyGroup + { + ExternalGroupId = request.ExternalGroupId, + GroupName = request.GroupName, + Balance = request.InitialBalance ?? 0, + LifetimeCreditsAdded = request.InitialBalance ?? 0, + LifetimeSpent = 0 + }; - var id = await _groupRepository.CreateAsync(group); - group.Id = id; + var id = await _groupRepository.CreateAsync(group); + group.Id = id; - var dto = new VirtualKeyGroupDto - { - Id = group.Id, - ExternalGroupId = group.ExternalGroupId, - GroupName = group.GroupName, - Balance = group.Balance, - LifetimeCreditsAdded = group.LifetimeCreditsAdded, - LifetimeSpent = group.LifetimeSpent, - CreatedAt = group.CreatedAt, - UpdatedAt = group.UpdatedAt, - VirtualKeyCount = 0 - }; + LogAdminAudit("Created", "VirtualKeyGroup", id, + $"Name: {group.GroupName}, InitialBalance: {group.Balance}"); + AdminOperationsMetricsService.RecordConfigurationChange("virtualkeygroup", "create"); - return CreatedAtAction(nameof(GetGroup), new { id = group.Id }, dto); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating virtual key group"); - return StatusCode(500, new { message = "An error occurred while creating the group" }); - } + var dto = new VirtualKeyGroupDto + { + Id = group.Id, + ExternalGroupId = group.ExternalGroupId, + GroupName = group.GroupName, + Balance = group.Balance, + LifetimeCreditsAdded = group.LifetimeCreditsAdded, + LifetimeSpent = group.LifetimeSpent, + CreatedAt = group.CreatedAt, + UpdatedAt = group.UpdatedAt, + VirtualKeyCount = 0 + }; + + return (IActionResult)CreatedAtAction(nameof(GetGroup), new { id = group.Id }, dto); + }, + r => r, + "CreateGroup"); } /// /// Update a virtual key group /// [HttpPut("{id}")] - public async Task UpdateGroup(int id, [FromBody] UpdateVirtualKeyGroupRequestDto request) + public Task UpdateGroup(int id, [FromBody] UpdateVirtualKeyGroupRequestDto request) { - try - { - var group = await _groupRepository.GetByIdAsync(id); - if (group == null) + return ExecuteAsync( + async () => { - return NotFound(new { message = "Group not found" }); - } + var group = await _groupRepository.GetByIdAsync(id); + if (group == null) + throw new KeyNotFoundException(); - if (!string.IsNullOrEmpty(request.GroupName)) - { - group.GroupName = request.GroupName; - } + var changes = new List<(string Property, string? OldValue, string? NewValue)>(); - if (!string.IsNullOrEmpty(request.ExternalGroupId)) - { - group.ExternalGroupId = request.ExternalGroupId; - } + if (!string.IsNullOrEmpty(request.GroupName)) + { + changes.Add(("GroupName", group.GroupName, request.GroupName)); + group.GroupName = request.GroupName; + } - await _groupRepository.UpdateAsync(group); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating virtual key group {GroupId}", id); - return StatusCode(500, new { message = "An error occurred while updating the group" }); - } + if (!string.IsNullOrEmpty(request.ExternalGroupId)) + { + changes.Add(("ExternalGroupId", group.ExternalGroupId, request.ExternalGroupId)); + group.ExternalGroupId = request.ExternalGroupId; + } + + await _groupRepository.UpdateAsync(group); + + LogAdminAuditWithChanges("VirtualKeyGroup", id, changes); + AdminOperationsMetricsService.RecordConfigurationChange("virtualkeygroup", "update"); + }, + NoContent(), + "UpdateGroup", + new { Id = id }); } /// /// Adjust the balance of a virtual key group /// [HttpPost("{id}/adjust-balance")] - public async Task> AdjustBalance(int id, [FromBody] AdjustBalanceDto request) + public Task AdjustBalance(int id, [FromBody] AdjustBalanceDto request) { - try - { - // Get the authenticated user's identity - var initiatedBy = User.Identity?.Name ?? "System"; - - var newBalance = await _groupRepository.AdjustBalanceAsync( - id, - request.Amount, - request.Description, - initiatedBy - ); - - var group = await _groupRepository.GetByIdAsync(id); - if (group == null) + return ExecuteAsync( + async () => { - return NotFound(new { message = "Group not found" }); - } + // Get the authenticated user's identity + var initiatedBy = User.Identity?.Name ?? "System"; - var dto = new VirtualKeyGroupDto - { - Id = group.Id, - ExternalGroupId = group.ExternalGroupId, - GroupName = group.GroupName, - Balance = group.Balance, - LifetimeCreditsAdded = group.LifetimeCreditsAdded, - LifetimeSpent = group.LifetimeSpent, - CreatedAt = group.CreatedAt, - UpdatedAt = group.UpdatedAt, - VirtualKeyCount = group.VirtualKeys?.Count ?? 0 - }; + var newBalance = await _groupRepository.AdjustBalanceAsync( + id, + request.Amount, + request.Description, + initiatedBy + ); - return Ok(dto); - } - catch (InvalidOperationException ex) - { - return BadRequest(new { message = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adjusting balance for virtual key group {GroupId}", id); - return StatusCode(500, new { message = "An error occurred while adjusting the balance" }); - } + LogAdminAudit("AdjustedBalance", "VirtualKeyGroup", id, + $"Amount: {request.Amount}, Description: {request.Description}, NewBalance: {newBalance}"); + + var group = await _groupRepository.GetByIdAsync(id); + if (group == null) + throw new KeyNotFoundException(); + + return (object)new VirtualKeyGroupDto + { + Id = group.Id, + ExternalGroupId = group.ExternalGroupId, + GroupName = group.GroupName, + Balance = group.Balance, + LifetimeCreditsAdded = group.LifetimeCreditsAdded, + LifetimeSpent = group.LifetimeSpent, + CreatedAt = group.CreatedAt, + UpdatedAt = group.UpdatedAt, + VirtualKeyCount = group.VirtualKeys?.Count ?? 0 + }; + }, + Ok, + "AdjustBalance", + new { Id = id }); } /// /// Delete a virtual key group /// [HttpDelete("{id}")] - public async Task DeleteGroup(int id) + public Task DeleteGroup(int id) { - try - { - var group = await _groupRepository.GetByIdAsync(id); - if (group == null) - { - return NotFound(new { message = "Group not found" }); - } - - // Check if group has any keys - if (group.VirtualKeys?.Count > 0) + return ExecuteAsync( + async () => { - return BadRequest(new { message = "Cannot delete group with existing virtual keys" }); - } - - await _groupRepository.DeleteAsync(id); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting virtual key group {GroupId}", id); - return StatusCode(500, new { message = "An error occurred while deleting the group" }); - } + var group = await _groupRepository.GetByIdAsync(id); + if (group == null) + throw new KeyNotFoundException(); + + // Check if group has any keys + if (group.VirtualKeys?.Count > 0) + throw new InvalidOperationException("Cannot delete group with existing virtual keys"); + + await _groupRepository.DeleteAsync(id); + + LogAdminAudit("Deleted", "VirtualKeyGroup", id, + $"Name: {group.GroupName}"); + AdminOperationsMetricsService.RecordConfigurationChange("virtualkeygroup", "delete"); + }, + NoContent(), + "DeleteGroup", + new { Id = id }); } /// @@ -279,111 +276,100 @@ public async Task DeleteGroup(int id) [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetTransactionHistory( - int id, + public Task GetTransactionHistory( + int id, [FromQuery] int page = 1, [FromQuery] int pageSize = 50) { - try - { - var group = await _groupRepository.GetByIdAsync(id); - if (group == null) + // Validate page parameters + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > 100) pageSize = 100; + + return ExecuteAsync( + async () => { - return NotFound(new { message = "Group not found" }); - } - - // Validate page parameters - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 50; - if (pageSize > 100) pageSize = 100; - - // Get total count (soft delete filter applied automatically via named query filter) - var totalCount = await _context.VirtualKeyGroupTransactions - .Where(t => t.VirtualKeyGroupId == id) - .CountAsync(); - - // Calculate pagination - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - var skip = (page - 1) * pageSize; - - // Get paginated transactions (soft delete filter applied automatically via named query filter) - var transactions = await _context.VirtualKeyGroupTransactions - .Where(t => t.VirtualKeyGroupId == id) - .OrderByDescending(t => t.CreatedAt) - .Skip(skip) - .Take(pageSize) - .Select(t => new VirtualKeyGroupTransactionDto + var group = await _groupRepository.GetByIdAsync(id); + if (group == null) + throw new KeyNotFoundException(); + + // Get total count (soft delete filter applied automatically via named query filter) + var totalCount = await _context.VirtualKeyGroupTransactions + .Where(t => t.VirtualKeyGroupId == id) + .CountAsync(); + + // Calculate pagination + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var skip = (page - 1) * pageSize; + + // Get paginated transactions (soft delete filter applied automatically via named query filter) + var transactions = await _context.VirtualKeyGroupTransactions + .Where(t => t.VirtualKeyGroupId == id) + .OrderByDescending(t => t.CreatedAt) + .Skip(skip) + .Take(pageSize) + .Select(t => new VirtualKeyGroupTransactionDto + { + Id = t.Id, + VirtualKeyGroupId = t.VirtualKeyGroupId, + TransactionType = t.TransactionType, + Amount = t.Amount, + BalanceAfter = t.BalanceAfter, + Description = t.Description, + ReferenceId = t.ReferenceId, + ReferenceType = t.ReferenceType, + InitiatedBy = t.InitiatedBy, + InitiatedByUserId = t.InitiatedByUserId, + CreatedAt = t.CreatedAt + }) + .ToListAsync(); + + return (object)new PagedResult { - Id = t.Id, - VirtualKeyGroupId = t.VirtualKeyGroupId, - TransactionType = t.TransactionType, - Amount = t.Amount, - BalanceAfter = t.BalanceAfter, - Description = t.Description, - ReferenceId = t.ReferenceId, - ReferenceType = t.ReferenceType, - InitiatedBy = t.InitiatedBy, - InitiatedByUserId = t.InitiatedByUserId, - CreatedAt = t.CreatedAt - }) - .ToListAsync(); - - var result = new PagedResult - { - Items = transactions, - TotalCount = totalCount, - CurrentPage = page, - PageSize = pageSize, - TotalPages = totalPages - }; - - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving transaction history for virtual key group {GroupId}", id); - return StatusCode(500, new { message = "An error occurred while retrieving the transaction history" }); - } + Items = transactions, + TotalCount = totalCount, + CurrentPage = page, + PageSize = pageSize, + TotalPages = totalPages + }; + }, + Ok, + "GetTransactionHistory", + new { Id = id }); } /// /// Get virtual keys in a group /// [HttpGet("{id}/keys")] - public async Task>> GetKeysInGroup(int id) + public Task GetKeysInGroup(int id) { - try - { - var group = await _groupRepository.GetByIdWithKeysAsync(id); - if (group == null) - { - return NotFound(new { message = "Group not found" }); - } - - var keys = group.VirtualKeys?.Select(k => new VirtualKeyDto + return ExecuteWithNotFoundAsync( + () => _groupRepository.GetByIdWithKeysAsync(id), + group => { - Id = k.Id, - KeyName = k.KeyName, - KeyPrefix = k.KeyHash?.Length > 10 ? k.KeyHash.Substring(0, 10) + "..." : k.KeyHash, - AllowedModels = k.AllowedModels, - VirtualKeyGroupId = k.VirtualKeyGroupId, - IsEnabled = k.IsEnabled, - ExpiresAt = k.ExpiresAt, - CreatedAt = k.CreatedAt, - UpdatedAt = k.UpdatedAt, - Metadata = k.Metadata, - RateLimitRpm = k.RateLimitRpm, - RateLimitRpd = k.RateLimitRpd, - Description = k.Description - }).ToList() ?? new List(); - - return Ok(keys); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving keys for virtual key group {GroupId}", id); - return StatusCode(500, new { message = "An error occurred while retrieving the keys" }); - } + var keys = group.VirtualKeys?.Select(k => new VirtualKeyDto + { + Id = k.Id, + KeyName = k.KeyName, + KeyPrefix = k.KeyHash?.Length > 10 ? k.KeyHash.Substring(0, 10) + "..." : k.KeyHash, + AllowedModels = k.AllowedModels, + VirtualKeyGroupId = k.VirtualKeyGroupId, + IsEnabled = k.IsEnabled, + ExpiresAt = k.ExpiresAt, + CreatedAt = k.CreatedAt, + UpdatedAt = k.UpdatedAt, + Metadata = k.Metadata, + RateLimitRpm = k.RateLimitRpm, + RateLimitRpd = k.RateLimitRpd, + Description = k.Description + }).ToList() ?? new List(); + + return Ok(keys); + }, + "VirtualKeyGroup", + id, + "GetKeysInGroup"); } /// @@ -397,73 +383,57 @@ public async Task>> GetKeysInGroup(int id) [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> ProcessRefund(int id, [FromBody] ProcessRefundRequestDto request) + public Task ProcessRefund(int id, [FromBody] ProcessRefundRequestDto request) { - try - { - // Validate request - if (string.IsNullOrEmpty(request.ModelId)) - { - return BadRequest(new { message = "Model ID is required" }); - } - - if (string.IsNullOrEmpty(request.RefundReason)) - { - return BadRequest(new { message = "Refund reason is required" }); - } - - // Get user info for audit trail - var initiatedBy = User.Identity?.Name ?? "System"; - var initiatedByUserId = User.FindFirst("sub")?.Value; // Clerk user ID from JWT - - // Convert DTOs to core models - var originalUsage = MapToUsage(request.OriginalUsage); - var refundUsage = MapToUsage(request.RefundUsage); - - // Process the refund - var refundResult = await _refundService.ProcessRefundAsync( - id, - request.ModelId, - originalUsage, - refundUsage, - request.RefundReason, - request.OriginalTransactionId, - initiatedBy, - initiatedByUserId); - - // Get updated group info for balance - var group = await _groupRepository.GetByIdAsync(id); - if (group == null) - { - return NotFound(new { message = "Group not found" }); - } - - // Map to response DTO - var responseDto = MapToRefundResultDto(refundResult, group.Balance); - - _logger.LogInformation( - "Refund processed for group {GroupId}: {RefundAmount:C}, Transaction ID: {TransactionId}", - id, - refundResult.RefundAmount, - refundResult.OriginalTransactionId); - - return Ok(responseDto); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Invalid operation while processing refund for group {GroupId}", id); - return NotFound(new { message = ex.Message }); - } - catch (ArgumentException ex) + // Validate request + if (string.IsNullOrEmpty(request.ModelId)) { - _logger.LogWarning(ex, "Invalid refund request for group {GroupId}", id); - return BadRequest(new { message = ex.Message }); + return Task.FromResult(BadRequest(new { message = "Model ID is required" })); } - catch (Exception ex) + + if (string.IsNullOrEmpty(request.RefundReason)) { - _logger.LogError(ex, "Error processing refund for virtual key group {GroupId}", id); - return StatusCode(500, new { message = "An error occurred while processing the refund" }); + return Task.FromResult(BadRequest(new { message = "Refund reason is required" })); } + + return ExecuteAsync( + async () => + { + // Get user info for audit trail + var initiatedBy = User.Identity?.Name ?? "System"; + var initiatedByUserId = User.FindFirst("sub")?.Value; // Clerk user ID from JWT + + // Convert DTOs to core models + var originalUsage = MapToUsage(request.OriginalUsage); + var refundUsage = MapToUsage(request.RefundUsage); + + // Process the refund + var refundResult = await _refundService.ProcessRefundAsync( + id, + request.ModelId, + originalUsage, + refundUsage, + request.RefundReason, + request.OriginalTransactionId, + initiatedBy, + initiatedByUserId); + + // Get updated group info for balance + var group = await _groupRepository.GetByIdAsync(id); + if (group == null) + throw new KeyNotFoundException(); + + // Map to response DTO + var responseDto = MapToRefundResultDto(refundResult, group.Balance); + + LogAdminAudit("Refunded", "VirtualKeyGroup", id, + $"Amount: {refundResult.RefundAmount:C}, Model: {request.ModelId}, Reason: {request.RefundReason}, TransactionId: {refundResult.OriginalTransactionId}"); + + return (object)responseDto; + }, + Ok, + "ProcessRefund", + new { Id = id }); } /// diff --git a/Services/ConduitLLM.Admin/Controllers/VirtualKeysController.cs b/Services/ConduitLLM.Admin/Controllers/VirtualKeysController.cs index 6b6e32496..f9ba7c7c6 100644 --- a/Services/ConduitLLM.Admin/Controllers/VirtualKeysController.cs +++ b/Services/ConduitLLM.Admin/Controllers/VirtualKeysController.cs @@ -1,11 +1,11 @@ using ConduitLLM.Core.Extensions; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Admin.Services; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.DTOs.VirtualKey; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace ConduitLLM.Admin.Controllers; @@ -14,10 +14,9 @@ namespace ConduitLLM.Admin.Controllers; /// [ApiController] [Route("api/[controller]")] -public class VirtualKeysController : ControllerBase +public class VirtualKeysController : AdminControllerBase { private readonly IAdminVirtualKeyService _virtualKeyService; - private readonly ILogger _logger; /// /// Initializes a new instance of the VirtualKeysController @@ -27,9 +26,9 @@ public class VirtualKeysController : ControllerBase public VirtualKeysController( IAdminVirtualKeyService virtualKeyService, ILogger logger) + : base(logger) { _virtualKeyService = virtualKeyService ?? throw new ArgumentNullException(nameof(virtualKeyService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -44,28 +43,19 @@ public VirtualKeysController( [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GenerateKey([FromBody] CreateVirtualKeyRequestDto request) + public Task GenerateKey([FromBody] CreateVirtualKeyRequestDto request) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var response = await _virtualKeyService.GenerateVirtualKeyAsync(request); - return CreatedAtAction(nameof(GetKeyById), new { id = response.KeyInfo.Id }, response); - } - catch (DbUpdateException dbEx) - { - _logger.LogError(dbEx, "Database update error creating virtual key named {KeyName}. Check for constraint violations.", LoggingSanitizer.S(request.KeyName)); - return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while saving the key. It might violate a unique constraint (e.g., duplicate name)." }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating virtual key for '{KeyName}'", LoggingSanitizer.S(request.KeyName)); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _virtualKeyService.GenerateVirtualKeyAsync(request), + response => + { + LogAdminAudit("Created", "VirtualKey", response.KeyInfo.Id, $"Name: {LoggingSanitizer.S(request.KeyName)}"); + AdminOperationsMetricsService.RecordVirtualKeyOperation("create", "success"); + AdminOperationsMetricsService.RecordConfigurationChange("virtualkey", "create"); + return CreatedAtAction(nameof(GetKeyById), new { id = response.KeyInfo.Id }, response); + }, + "GenerateKey", + new { KeyName = LoggingSanitizer.S(request.KeyName) }); } /// @@ -77,18 +67,12 @@ public async Task GenerateKey([FromBody] CreateVirtualKeyRequestD [Authorize(Policy = "MasterKeyPolicy")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ListKeys([FromQuery] int? virtualKeyGroupId = null) + public Task ListKeys([FromQuery] int? virtualKeyGroupId = null) { - try - { - var keys = await _virtualKeyService.ListVirtualKeysAsync(virtualKeyGroupId); - return Ok(keys); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error listing virtual keys."); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _virtualKeyService.ListVirtualKeysAsync(virtualKeyGroupId), + Ok, + "ListKeys"); } /// @@ -101,22 +85,14 @@ public async Task ListKeys([FromQuery] int? virtualKeyGroupId = n [ProducesResponseType(typeof(VirtualKeyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetKeyById(int id) + public Task GetKeyById(int id) { - try - { - var key = await _virtualKeyService.GetVirtualKeyInfoAsync(id); - if (key == null) - { - return NotFound(); - } - return Ok(key); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting virtual key with ID {KeyId}.", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _virtualKeyService.GetVirtualKeyInfoAsync(id), + Ok, + "Virtual key", + id, + "GetKeyById"); } /// @@ -133,27 +109,51 @@ public async Task GetKeyById(int id) [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task UpdateKey(int id, [FromBody] UpdateVirtualKeyRequestDto request) + public Task UpdateKey(int id, [FromBody] UpdateVirtualKeyRequestDto request) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var success = await _virtualKeyService.UpdateVirtualKeyAsync(id, request); - if (!success) + return ExecuteAsync( + async () => { - return NotFound(); - } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating virtual key with ID {KeyId}.", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + // Fetch pre-state for change tracking + var preState = await _virtualKeyService.GetVirtualKeyInfoAsync(id); + if (preState == null) + throw new KeyNotFoundException(); + + if (!await _virtualKeyService.UpdateVirtualKeyAsync(id, request)) + throw new KeyNotFoundException(); + + // Build change list from pre-state vs request + var changes = new List<(string Property, string? OldValue, string? NewValue)>(); + + if (request.KeyName != null && preState.KeyName != request.KeyName) + changes.Add(("KeyName", preState.KeyName, request.KeyName)); + if (request.IsEnabled.HasValue && preState.IsEnabled != request.IsEnabled.Value) + changes.Add(("IsEnabled", preState.IsEnabled.ToString(), request.IsEnabled.Value.ToString())); + if (request.AllowedModels != null && preState.AllowedModels != request.AllowedModels) + changes.Add(("AllowedModels", preState.AllowedModels ?? "null", request.AllowedModels)); + if (request.ExpiresAt.HasValue && preState.ExpiresAt != request.ExpiresAt) + changes.Add(("ExpiresAt", preState.ExpiresAt?.ToString("o") ?? "null", request.ExpiresAt?.ToString("o") ?? "null")); + if (request.RateLimitRpm.HasValue && preState.RateLimitRpm != request.RateLimitRpm) + changes.Add(("RateLimitRpm", preState.RateLimitRpm?.ToString() ?? "null", request.RateLimitRpm?.ToString() ?? "null")); + if (request.RateLimitRpd.HasValue && preState.RateLimitRpd != request.RateLimitRpd) + changes.Add(("RateLimitRpd", preState.RateLimitRpd?.ToString() ?? "null", request.RateLimitRpd?.ToString() ?? "null")); + if (request.VirtualKeyGroupId.HasValue && preState.VirtualKeyGroupId != request.VirtualKeyGroupId.Value) + changes.Add(("VirtualKeyGroupId", preState.VirtualKeyGroupId.ToString(), request.VirtualKeyGroupId.Value.ToString())); + + if (changes.Count > 0) + { + LogAdminAuditWithChanges("VirtualKey", id, changes); + } + else + { + LogAdminAudit("Updated", "VirtualKey", id, "No changes detected"); + } + AdminOperationsMetricsService.RecordVirtualKeyOperation("update", "success"); + AdminOperationsMetricsService.RecordConfigurationChange("virtualkey", "update"); + }, + NoContent(), + "UpdateKey", + new { Id = id }); } /// @@ -168,22 +168,20 @@ public async Task UpdateKey(int id, [FromBody] UpdateVirtualKeyRe [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task DeleteKey(int id) + public Task DeleteKey(int id) { - try - { - var success = await _virtualKeyService.DeleteVirtualKeyAsync(id); - if (!success) + return ExecuteAsync( + async () => { - return NotFound(); - } - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting virtual key with ID {KeyId}.", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + if (!await _virtualKeyService.DeleteVirtualKeyAsync(id)) + throw new KeyNotFoundException(); + LogAdminAudit("Deleted", "VirtualKey", id); + AdminOperationsMetricsService.RecordVirtualKeyOperation("delete", "success"); + AdminOperationsMetricsService.RecordConfigurationChange("virtualkey", "delete"); + }, + NoContent(), + "DeleteKey", + new { Id = id }); } @@ -197,23 +195,12 @@ public async Task DeleteKey(int id) [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] // lgtm [cs/web/missing-function-level-access-control] - public async Task ValidateKey([FromBody] ValidateVirtualKeyRequest request) + public Task ValidateKey([FromBody] ValidateVirtualKeyRequest request) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var result = await _virtualKeyService.ValidateVirtualKeyAsync(request.Key, request.RequestedModel); - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error validating virtual key"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteAsync( + () => _virtualKeyService.ValidateVirtualKeyAsync(request.Key, request.RequestedModel), + Ok, + "ValidateKey"); } @@ -229,22 +216,14 @@ public async Task ValidateKey([FromBody] ValidateVirtualKeyReques [ProducesResponseType(typeof(VirtualKeyValidationInfoDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetValidationInfo(int id) + public Task GetValidationInfo(int id) { - try - { - var info = await _virtualKeyService.GetValidationInfoAsync(id); - if (info == null) - { - return NotFound(); - } - return Ok(info); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting validation info for virtual key with ID {KeyId}.", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _virtualKeyService.GetValidationInfoAsync(id), + Ok, + "Virtual key", + id, + "GetValidationInfo"); } /// @@ -263,18 +242,12 @@ public async Task GetValidationInfo(int id) [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task PerformMaintenance() + public Task PerformMaintenance() { - try - { - await _virtualKeyService.PerformMaintenanceAsync(); - return NoContent(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error performing virtual key maintenance"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred during maintenance."); - } + return ExecuteAsync( + () => _virtualKeyService.PerformMaintenanceAsync(), + NoContent(), + "PerformMaintenance"); } /// @@ -288,22 +261,14 @@ public async Task PerformMaintenance() [ProducesResponseType(typeof(VirtualKeyDiscoveryPreviewDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task PreviewDiscovery(int id, [FromQuery] string? capability = null) + public Task PreviewDiscovery(int id, [FromQuery] string? capability = null) { - try - { - var preview = await _virtualKeyService.PreviewDiscoveryAsync(id, capability); - if (preview == null) - { - return NotFound(); - } - return Ok(preview); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error previewing discovery for virtual key with ID {KeyId}.", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _virtualKeyService.PreviewDiscoveryAsync(id, capability), + Ok, + "Virtual key", + id, + "PreviewDiscovery"); } /// @@ -316,29 +281,28 @@ public async Task PreviewDiscovery(int id, [FromQuery] string? ca [ProducesResponseType(typeof(VirtualKeyGroupDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetKeyGroup(int id) + public Task GetKeyGroup(int id) { - try - { - var key = await _virtualKeyService.GetVirtualKeyByIdAsync(id); - if (key == null) + return ExecuteAsync( + async () => { - return NotFound(new { message = "Virtual key not found" }); - } + var key = await _virtualKeyService.GetVirtualKeyByIdAsync(id); + if (key == null) + { + throw new KeyNotFoundException("Virtual key not found"); + } - var groupInfo = await _virtualKeyService.GetKeyGroupAsync(id); - if (groupInfo == null) - { - return NotFound(new { message = "Virtual key group not found" }); - } + var groupInfo = await _virtualKeyService.GetKeyGroupAsync(id); + if (groupInfo == null) + { + throw new KeyNotFoundException("Virtual key group not found"); + } - return Ok(groupInfo); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting group for virtual key with ID {KeyId}.", id); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return groupInfo; + }, + Ok, + "GetKeyGroup", + new { Id = id }); } /// @@ -347,8 +311,8 @@ public async Task GetKeyGroup(int id) /// The virtual key value (with prefix) /// Usage information including balance, spending, and request counts /// - /// This endpoint allows administrators to check the usage and balance of a virtual key - /// using the actual key value instead of the database ID. This is useful for support + /// This endpoint allows administrators to check the usage and balance of a virtual key + /// using the actual key value instead of the database ID. This is useful for support /// scenarios where users provide their key value. /// [HttpGet("usage/by-key/{key}")] @@ -359,27 +323,18 @@ public async Task GetKeyGroup(int id) [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetUsageByKey(string key) + public Task GetUsageByKey(string key) { if (string.IsNullOrEmpty(key)) { - return BadRequest(new { message = "Key value is required" }); + return Task.FromResult(BadRequest(new { message = "Key value is required" })); } - try - { - var usage = await _virtualKeyService.GetUsageByKeyAsync(key); - if (usage == null) - { - return NotFound(new { message = "Virtual key not found or invalid key format" }); - } - - return Ok(usage); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting usage for virtual key"); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred."); - } + return ExecuteWithNotFoundAsync( + () => _virtualKeyService.GetUsageByKeyAsync(key), + Ok, + "Virtual key", + null, + "GetUsageByKey"); } } diff --git a/Services/ConduitLLM.Admin/DTOs/MediaRetentionDtos.cs b/Services/ConduitLLM.Admin/DTOs/MediaRetentionDtos.cs new file mode 100644 index 000000000..6455c3f18 --- /dev/null +++ b/Services/ConduitLLM.Admin/DTOs/MediaRetentionDtos.cs @@ -0,0 +1,94 @@ +namespace ConduitLLM.Admin.Controllers +{ + /// + /// Data transfer object for media retention policy information. + /// + public class MediaRetentionPolicyDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public int PositiveBalanceRetentionDays { get; set; } + public int ZeroBalanceRetentionDays { get; set; } + public int NegativeBalanceRetentionDays { get; set; } + public int SoftDeleteGracePeriodDays { get; set; } + public bool RespectRecentAccess { get; set; } + public int RecentAccessWindowDays { get; set; } + public bool IsDefault { get; set; } + public long? MaxStorageSizeBytes { get; set; } + public int? MaxFileCount { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public int VirtualKeyGroupCount { get; set; } + } + + /// + /// Extended DTO for media retention policy with virtual key group details. + /// + public class MediaRetentionPolicyDetailDto : MediaRetentionPolicyDto + { + public List VirtualKeyGroups { get; set; } = new(); + } + + /// + /// Summary information for a virtual key group. + /// + public class VirtualKeyGroupSummaryDto + { + public int Id { get; set; } + public decimal Balance { get; set; } + public int VirtualKeyCount { get; set; } + } + + /// + /// Request model for creating a new media retention policy. + /// + public class CreateMediaRetentionPolicyRequest + { + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public int PositiveBalanceRetentionDays { get; set; } + public int ZeroBalanceRetentionDays { get; set; } + public int NegativeBalanceRetentionDays { get; set; } + public int SoftDeleteGracePeriodDays { get; set; } = 7; + public bool RespectRecentAccess { get; set; } = true; + public int RecentAccessWindowDays { get; set; } = 7; + public bool IsDefault { get; set; } + public long? MaxStorageSizeBytes { get; set; } + public int? MaxFileCount { get; set; } + } + + /// + /// Request model for updating an existing media retention policy. + /// + public class UpdateMediaRetentionPolicyRequest + { + public string? Name { get; set; } + public string? Description { get; set; } + public int? PositiveBalanceRetentionDays { get; set; } + public int? ZeroBalanceRetentionDays { get; set; } + public int? NegativeBalanceRetentionDays { get; set; } + public int? SoftDeleteGracePeriodDays { get; set; } + public bool? RespectRecentAccess { get; set; } + public int? RecentAccessWindowDays { get; set; } + public bool? IsDefault { get; set; } + public long? MaxStorageSizeBytes { get; set; } + public int? MaxFileCount { get; set; } + public bool? IsActive { get; set; } + } + + /// + /// Represents the result of a media cleanup operation. + /// + public class CleanupResultDto + { + public int VirtualKeyGroupId { get; set; } + public bool DryRun { get; set; } + public int MediaRecordsEvaluated { get; set; } + public int MediaRecordsMarkedForDeletion { get; set; } + public int MediaRecordsDeleted { get; set; } + public long StorageBytesFreed { get; set; } + public string Message { get; set; } = string.Empty; + } +} diff --git a/Services/ConduitLLM.Admin/Extensions/ConfigurationExtensions.cs b/Services/ConduitLLM.Admin/Extensions/ConfigurationExtensions.cs index 6dbe2de73..92da6953c 100644 --- a/Services/ConduitLLM.Admin/Extensions/ConfigurationExtensions.cs +++ b/Services/ConduitLLM.Admin/Extensions/ConfigurationExtensions.cs @@ -1,8 +1,5 @@ -using ConduitLLM.Configuration; using ConduitLLM.Configuration.Extensions; -using ConduitLLM.Configuration.Interfaces; -using ConduitLLM.Configuration.Services; -using ConduitLLM.Core.Services; +using ConduitLLM.Core.Extensions; namespace ConduitLLM.Admin.Extensions { @@ -28,23 +25,9 @@ public static IServiceCollection AddConfigurationServices(this IServiceCollectio // Add database initialization services.AddDatabaseInitialization(); - // Global settings cache service - loads settings at startup and provides fast access - services.AddSingleton(); - services.AddHostedService(provider => provider.GetRequiredService() as GlobalSettingsCacheService - ?? throw new InvalidOperationException("GlobalSettingsCacheService must be registered as singleton")); - - // Add Configuration services - services.AddScoped(); - - // Register model provider mapping service with caching decorator pattern - services.AddScoped(); // Inner service - services.AddScoped(provider => - { - var innerService = provider.GetRequiredService(); - var cacheManager = provider.GetRequiredService(); - var logger = provider.GetRequiredService>(); - return new CachedModelProviderMappingService(innerService, cacheManager, logger); - }); + // Shared application services (GlobalSettingsCache, ProviderService, + // ModelProviderMapping+decorator, ProviderMetadataRegistry) + services.AddSharedApplicationServices(); return services; } diff --git a/Services/ConduitLLM.Admin/Extensions/ControllerErrorExtensions.cs b/Services/ConduitLLM.Admin/Extensions/ControllerErrorExtensions.cs new file mode 100644 index 000000000..1744a806c --- /dev/null +++ b/Services/ConduitLLM.Admin/Extensions/ControllerErrorExtensions.cs @@ -0,0 +1,195 @@ +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Exceptions; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Admin.Extensions +{ + /// + /// Extension methods for standardized error responses in controllers. + /// + /// + /// These extensions ensure consistent error response format across all Admin API controllers. + /// All error responses use the format for consistency. + /// + public static class ControllerErrorExtensions + { + /// + /// Creates a standardized 400 Bad Request response. + /// + /// The controller instance. + /// The error message. + /// Optional error code for programmatic handling. + /// A BadRequest result with standardized error format. + public static BadRequestObjectResult BadRequestError( + this ControllerBase controller, + string message, + string? code = null) + { + return controller.BadRequest(new ErrorResponseDto(message) { Code = code }); + } + + /// + /// Creates a standardized 404 Not Found response. + /// + /// The controller instance. + /// The error message. + /// Optional error code for programmatic handling. + /// A NotFound result with standardized error format. + public static NotFoundObjectResult NotFoundError( + this ControllerBase controller, + string message, + string? code = null) + { + return controller.NotFound(new ErrorResponseDto(message) { Code = code }); + } + + /// + /// Creates a standardized 404 Not Found response for a specific entity type. + /// + /// The controller instance. + /// The type of entity that was not found (e.g., "Provider", "VirtualKey"). + /// Optional identifier of the entity. + /// A NotFound result with standardized error format. + public static NotFoundObjectResult NotFoundEntity( + this ControllerBase controller, + string entityType, + object? entityId = null) + { + var message = entityId != null + ? $"{entityType} with ID '{entityId}' not found" + : $"{entityType} not found"; + return controller.NotFound(new ErrorResponseDto(message) { Code = "not_found" }); + } + + /// + /// Creates a standardized 409 Conflict response. + /// + /// The controller instance. + /// The error message. + /// Optional error code for programmatic handling. + /// A Conflict result with standardized error format. + public static ConflictObjectResult ConflictError( + this ControllerBase controller, + string message, + string? code = null) + { + return controller.Conflict(new ErrorResponseDto(message) { Code = code }); + } + + /// + /// Creates a standardized 500 Internal Server Error response. + /// + /// The controller instance. + /// The error message (defaults to generic message for security). + /// Optional additional details (only include in non-production environments). + /// An ObjectResult with 500 status code and standardized error format. + public static ObjectResult InternalServerError( + this ControllerBase controller, + string message = "An unexpected error occurred.", + string? details = null) + { + var error = new ErrorResponseDto(message) { Details = details, Code = "internal_error" }; + return controller.StatusCode(StatusCodes.Status500InternalServerError, error); + } + + /// + /// Creates a standardized 503 Service Unavailable response. + /// + /// The controller instance. + /// The error message. + /// Optional error code for programmatic handling. + /// An ObjectResult with 503 status code and standardized error format. + public static ObjectResult ServiceUnavailableError( + this ControllerBase controller, + string message, + string? code = null) + { + var error = new ErrorResponseDto(message) { Code = code ?? "service_unavailable" }; + return controller.StatusCode(StatusCodes.Status503ServiceUnavailable, error); + } + + /// + /// Creates a standardized 422 Unprocessable Entity response for validation errors. + /// + /// The controller instance. + /// The validation error message. + /// Optional error code for programmatic handling. + /// An UnprocessableEntity result with standardized error format. + public static UnprocessableEntityObjectResult ValidationError( + this ControllerBase controller, + string message, + string? code = null) + { + return controller.UnprocessableEntity(new ErrorResponseDto(message) { Code = code ?? "validation_error" }); + } + + #region Common Validation Helpers + + /// + /// Validates that a date range has From <= To. + /// Returns a BadRequest result if invalid, or null if valid. + /// + public static IActionResult? ValidateDateRange(DateTime from, DateTime to) + { + if (from > to) + return new BadRequestObjectResult("From date must be before or equal to To date"); + return null; + } + + private static readonly string[] ValidTimeframes = { "daily", "weekly", "monthly" }; + + /// + /// Validates that a timeframe string is one of: daily, weekly, monthly. + /// Returns a BadRequest result if invalid, or null if valid. + /// + public static IActionResult? ValidateTimeframe(string timeframe, string paramName = "Timeframe") + { + if (!ValidTimeframes.Contains(timeframe.ToLowerInvariant())) + return new BadRequestObjectResult($"{paramName} must be one of: daily, weekly, monthly"); + return null; + } + + #endregion + + /// + /// Creates an appropriate error response from an exception. + /// Uses for consistent exception-to-response mapping. + /// + /// The controller instance. + /// The exception that occurred. + /// Optional logger for error logging. + /// Optional context message for logging. + /// An appropriate error result based on the exception type. + public static IActionResult HandleException( + this ControllerBase controller, + Exception ex, + ILogger? logger = null, + string? contextMessage = null) + { + var logMessage = contextMessage ?? "An error occurred"; + var mapping = ExceptionToResponseMapper.Map(ex); + + // Log at appropriate level with context + if (mapping.IncludeExceptionMessageInLog) + { + logger?.Log(mapping.LogLevel, ex, "{LogMessage}: {ExceptionMessage}", logMessage, ex.Message); + } + else if (mapping.LogLevel == LogLevel.Error) + { + logger?.LogError(ex, "{LogMessage}", logMessage); + } + else + { + logger?.LogWarning("{LogMessage}: {LogPrefix}", logMessage, mapping.LogPrefix); + } + + // Return standardized response + return new ObjectResult(new ErrorResponseDto(mapping.ResponseMessage) { Code = mapping.ErrorCode }) + { + StatusCode = mapping.StatusCode + }; + } + } +} diff --git a/Services/ConduitLLM.Admin/Extensions/CoreExtensions.cs b/Services/ConduitLLM.Admin/Extensions/CoreExtensions.cs index e1d0080c6..c8d1d685e 100644 --- a/Services/ConduitLLM.Admin/Extensions/CoreExtensions.cs +++ b/Services/ConduitLLM.Admin/Extensions/CoreExtensions.cs @@ -5,6 +5,7 @@ using ConduitLLM.Core.Services; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace ConduitLLM.Admin.Extensions { @@ -18,8 +19,9 @@ public static class CoreExtensions /// /// The service collection /// The application configuration + /// Optional logger for startup diagnostics /// The service collection for chaining - public static IServiceCollection AddCoreServices(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddCoreServices(this IServiceCollection services, IConfiguration configuration, ILogger? startupLogger = null) { // Register unified cache manager (required by CacheManagementService) services.AddCacheManager(configuration); @@ -30,17 +32,17 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Register DbContext Factory (using connection string from environment variables) var connectionStringManager = new ConnectionStringManager(); // Pass "AdminAPI" to get Admin API-specific connection pool settings - var (dbProvider, dbConnectionString) = connectionStringManager.GetProviderAndConnectionString("AdminAPI", msg => Console.WriteLine(msg)); - + var (dbProvider, dbConnectionString) = connectionStringManager.GetProviderAndConnectionString("AdminAPI", msg => startupLogger?.LogInformation("{Message}", msg)); + // Log the connection pool settings for verification if (dbProvider == "postgres" && dbConnectionString.Contains("MaxPoolSize")) { - Console.WriteLine($"[ConduitLLM.Admin] Admin API database connection pool configured:"); var match = System.Text.RegularExpressions.Regex.Match(dbConnectionString, @"MinPoolSize=(\d+);MaxPoolSize=(\d+)"); if (match.Success) { - Console.WriteLine($"[ConduitLLM.Admin] Min Pool Size: {match.Groups[1].Value}"); - Console.WriteLine($"[ConduitLLM.Admin] Max Pool Size: {match.Groups[2].Value}"); + startupLogger?.LogInformation( + "Admin API database connection pool configured โ€” MinPoolSize: {MinPoolSize}, MaxPoolSize: {MaxPoolSize}", + match.Groups[1].Value, match.Groups[2].Value); } } @@ -50,10 +52,18 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service throw new InvalidOperationException($"Only PostgreSQL is supported. Invalid provider: {dbProvider}"); } - services.AddDbContextFactory(options => + // Configure query monitoring for performance tracking + services.Configure( + configuration.GetSection(ConduitLLM.Configuration.Interceptors.QueryMonitoringOptions.SectionName)); + services.AddSingleton(); + + services.AddDbContextFactory((sp, options) => { - options.UseNpgsql(dbConnectionString); + var interceptor = sp.GetRequiredService(); + options.UseNpgsql(dbConnectionString) + .AddInterceptors(interceptor); }); + startupLogger?.LogInformation("Query monitoring interceptor configured for performance tracking"); // Also add scoped registration from factory for services that need direct injection // Note: This creates contexts from the factory on demand @@ -76,11 +86,12 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service // Add Function Discovery Cache for function tool definition caching services.AddFunctionDiscoveryCache(configuration); - Console.WriteLine("[ConduitLLM.Admin] Function Discovery Cache registered - function tool definitions will be cached based on per-function TTL"); + startupLogger?.LogInformation("Function Discovery Cache registered โ€” function tool definitions will be cached based on per-function TTL"); + + // Note: ProviderMetadataRegistry is registered via AddSharedApplicationServices() in ConfigurationExtensions - // Add Provider Registry - single source of truth for provider metadata - services.AddSingleton(); - Console.WriteLine("[ConduitLLM.Admin] Provider Registry registered - centralized provider metadata management enabled"); + // Add correlation context services for cross-service request tracing + services.AddCorrelationContext(); return services; } diff --git a/Services/ConduitLLM.Admin/Extensions/EntityMappingExtensions.cs b/Services/ConduitLLM.Admin/Extensions/EntityMappingExtensions.cs new file mode 100644 index 000000000..d2e8a3346 --- /dev/null +++ b/Services/ConduitLLM.Admin/Extensions/EntityMappingExtensions.cs @@ -0,0 +1,171 @@ +using ConduitLLM.Admin.Models.ModelAuthors; +using ConduitLLM.Admin.Models.Models; +using ConduitLLM.Admin.Models.ModelSeries; +using ConduitLLM.Configuration.DTOs.IpFilter; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Functions.DTOs; +using ConduitLLM.Functions.Entities; + +namespace ConduitLLM.Admin.Extensions +{ + /// + /// Extension methods for converting entities to their DTO representations + /// + public static class EntityMappingExtensions + { + /// + /// Maps an IpFilterEntity to an IpFilterDto + /// + public static IpFilterDto ToDto(this IpFilterEntity entity) + { + return new IpFilterDto + { + Id = entity.Id, + FilterType = entity.FilterType, + IpAddressOrCidr = entity.IpAddressOrCidr, + Description = entity.Description, + IsEnabled = entity.IsEnabled, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + CreatedBy = entity.CreatedBy, + UpdatedBy = entity.UpdatedBy + }; + } + + /// + /// Maps a ModelSeries entity to a ModelSeriesDto + /// + public static ModelSeriesDto ToDto(this ModelSeries series) + { + return new ModelSeriesDto + { + Id = series.Id, + AuthorId = series.AuthorId, + AuthorName = series.Author?.Name, + Name = series.Name, + Description = series.Description, + TokenizerType = series.TokenizerType, + Parameters = series.Parameters + }; + } + + /// + /// Maps a Model entity to a ModelDto + /// + public static ModelDto ToDto(this Model model) + { + return new ModelDto + { + Id = model.Id, + Name = model.Name, + ModelSeriesId = model.ModelSeriesId, + IsActive = model.IsActive, + CreatedAt = model.CreatedAt, + UpdatedAt = model.UpdatedAt, + Series = model.Series?.ToDto(), + ModelParameters = model.ModelParameters, + SupportsChat = model.SupportsChat, + SupportsVision = model.SupportsVision, + SupportsImageGeneration = model.SupportsImageGeneration, + SupportsVideoGeneration = model.SupportsVideoGeneration, + SupportsEmbeddings = model.SupportsEmbeddings, + SupportsFunctionCalling = model.SupportsFunctionCalling, + SupportsStreaming = model.SupportsStreaming, + MaxInputTokens = model.MaxInputTokens, + MaxOutputTokens = model.MaxOutputTokens, + TokenizerType = model.TokenizerType, + Identifiers = model.Identifiers?.Select(i => new ModelIdentifierDto + { + Id = i.Id, + Identifier = i.Identifier, + Provider = (int?)i.Provider, + IsPrimary = i.IsPrimary, + MaxInputTokens = i.MaxInputTokens, + MaxOutputTokens = i.MaxOutputTokens, + SpeedScore = i.SpeedScore, + QualityScore = i.QualityScore, + ProviderVariation = i.ProviderVariation, + ModelCostId = i.ModelCostId + }).ToList() + }; + } + + /// + /// Maps a ModelAuthor entity to a ModelAuthorDto + /// + public static ModelAuthorDto ToDto(this ModelAuthor author) + { + return new ModelAuthorDto + { + Id = author.Id, + Name = author.Name, + Description = author.Description, + WebsiteUrl = author.WebsiteUrl + }; + } + + /// + /// Maps a FunctionExecution entity to a FunctionExecutionDto + /// + public static FunctionExecutionDto ToDto(this FunctionExecution entity) + { + return new FunctionExecutionDto + { + Id = entity.Id, + FunctionConfigurationId = entity.FunctionConfigurationId, + VirtualKeyId = entity.VirtualKeyId, + ExecutionMode = entity.ExecutionMode, + State = entity.State, + RequestedAt = entity.RequestedAt, + StartedAt = entity.StartedAt, + CompletedAt = entity.CompletedAt, + Duration = entity.Duration?.TotalMilliseconds, + RequestJson = entity.RequestJson, + ResponseJson = entity.ResponseJson, + ErrorMessage = entity.ErrorMessage, + EstimatedCost = entity.EstimatedCost, + ActualCost = entity.ActualCost, + CostCalculationDetails = entity.CostCalculationDetails, + RetryCount = entity.RetryCount, + NextRetryAt = entity.NextRetryAt, + LeasedBy = entity.LeasedBy, + LeaseExpiryTime = entity.LeaseExpiryTime, + Version = entity.Version, + WebhookUrl = entity.WebhookUrl, + WebhookDelivered = entity.WebhookDelivered, + ProgressPercentage = entity.ProgressPercentage, + StatusMessage = entity.StatusMessage + }; + } + + /// + /// Maps a FunctionCost entity to a FunctionCostDto + /// + public static FunctionCostDto ToDto(this FunctionCost entity) + { + return new FunctionCostDto + { + Id = entity.Id, + CostName = entity.CostName, + ProviderType = entity.ProviderType, + Purpose = entity.Purpose, + Description = entity.Description, + BaseCost = entity.BaseCost, + PricingModel = entity.PricingModel, + CostPerExecution = entity.CostPerExecution, + CostPerResult = entity.CostPerResult, + CostPerToken = entity.CostPerToken, + CostPerMinute = entity.CostPerMinute, + TieredPricing = entity.TieredPricing, + PricingConfiguration = entity.PricingConfiguration, + IsActive = entity.IsActive, + EffectiveDate = entity.EffectiveDate, + ExpiryDate = entity.ExpiryDate, + Priority = entity.Priority, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; + } + + } +} diff --git a/Services/ConduitLLM.Admin/Extensions/MediaLifecycleExtensions.cs b/Services/ConduitLLM.Admin/Extensions/MediaLifecycleExtensions.cs index 0def83dc9..42d02e8aa 100644 --- a/Services/ConduitLLM.Admin/Extensions/MediaLifecycleExtensions.cs +++ b/Services/ConduitLLM.Admin/Extensions/MediaLifecycleExtensions.cs @@ -46,19 +46,6 @@ public static IServiceCollection AddMediaLifecycleServices( // Uses distributed locking to ensure only one instance runs across a cluster services.AddHostedService(); - // Log configuration - Console.WriteLine("[ConduitLLM.Admin] Media lifecycle services configured:"); - Console.WriteLine($" - Cleanup Enabled: {options.IsSchedulerEnabled}"); - Console.WriteLine($" - Dry Run Mode: {options.DryRunMode}"); - Console.WriteLine($" - Schedule Interval: {options.ScheduleIntervalMinutes} minutes"); - Console.WriteLine($" - Max Batch Size: {options.MaxBatchSize} items"); - Console.WriteLine($" - Monthly Delete Budget: {options.MonthlyDeleteBudget:N0} operations"); - - if (options.TestVirtualKeyGroups.Any()) - { - Console.WriteLine($" - Test Groups: {string.Join(", ", options.TestVirtualKeyGroups)}"); - } - return services; } @@ -68,34 +55,19 @@ private static void RegisterBudgetTrackingService( MediaLifecycleOptions options) { // Check if Redis is configured - var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); - var redisConnectionString = Environment.GetEnvironmentVariable("CONDUIT_REDIS_CONNECTION_STRING"); - - if (!string.IsNullOrEmpty(redisUrl)) - { - try - { - redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ParseRedisUrl(redisUrl); - } - catch - { - // Failed to parse REDIS_URL - } - } + var redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ResolveConnectionString(); if (!string.IsNullOrEmpty(redisConnectionString)) { // Redis is available - use Redis-based budget tracking // Note: IConnectionMultiplexer should already be registered by Admin API services.AddSingleton(); - Console.WriteLine($"[ConduitLLM.Admin] Media deletion budget tracking: Redis-backed (budget: {options.MonthlyDeleteBudget:N0}/month)"); } else { // No Redis - use in-memory tracking (development mode) services.AddSingleton(); - Console.WriteLine($"[ConduitLLM.Admin] Media deletion budget tracking: In-memory (budget: {options.MonthlyDeleteBudget:N0}/month)"); - Console.WriteLine("[ConduitLLM.Admin] WARNING: Budget tracking will not persist across restarts or be shared across instances"); + Console.Error.WriteLine("[ConduitLLM.Admin] WARNING: Budget tracking will not persist across restarts or be shared across instances"); } } @@ -152,13 +124,11 @@ private static void RegisterMediaStorageService( }); services.AddSingleton(); - Console.WriteLine($"[ConduitLLM.Admin] Media storage configured with S3-compatible service: {serviceUrl}"); } else { // Use in-memory storage for development/testing services.AddSingleton(); - Console.WriteLine("[ConduitLLM.Admin] Media storage configured with in-memory service (development mode)"); } } } diff --git a/Services/ConduitLLM.Admin/Extensions/RepositoryExtensions.cs b/Services/ConduitLLM.Admin/Extensions/RepositoryExtensions.cs index 1a6a23af4..3ed88d129 100644 --- a/Services/ConduitLLM.Admin/Extensions/RepositoryExtensions.cs +++ b/Services/ConduitLLM.Admin/Extensions/RepositoryExtensions.cs @@ -1,6 +1,6 @@ using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; - +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Admin.Extensions { @@ -10,7 +10,8 @@ namespace ConduitLLM.Admin.Extensions public static class RepositoryExtensions { /// - /// Gets daily costs from request logs within a specified date range + /// Gets daily costs from request logs within a specified date range. + /// Uses database-level aggregation instead of loading all logs into memory. /// /// The request log repository /// The start date (inclusive) @@ -23,18 +24,10 @@ public static class RepositoryExtensions DateTime endDate, CancellationToken cancellationToken = default) { - // Get the logs for the date range - var logs = await repository.GetByDateRangeAsync(startDate, endDate, cancellationToken); - - // Group by date and calculate daily costs - var dailyCosts = logs - .GroupBy(l => l.Timestamp.Date) - .Select(g => new { Date = g.Key, Cost = g.Sum(l => l.Cost) }) - .OrderBy(d => d.Date) - .Select(d => (d.Date, d.Cost)) + var aggregations = await repository.GetCostsByDateAsync(startDate, endDate, cancellationToken); + return aggregations + .Select(a => (a.Date, a.TotalCost)) .ToList(); - - return dailyCosts; } /// @@ -49,12 +42,14 @@ public static class RepositoryExtensions string keyName, CancellationToken cancellationToken = default) { - var keys = await repository.GetAllAsync(cancellationToken); + var keys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + repository.GetPaginatedAsync, cancellationToken: cancellationToken); return keys.FirstOrDefault(k => k.KeyName.Equals(keyName, StringComparison.OrdinalIgnoreCase)); } /// - /// Gets the spend history for a virtual key within a date range + /// Gets the spend history for a virtual key within a date range. + /// Delegates to the repository's database-level filtered query. /// /// The spend history repository /// The ID of the virtual key @@ -69,86 +64,11 @@ public static async Task> GetByKeyIdAndDateRangeAsy DateTime endDate, CancellationToken cancellationToken = default) { - var history = await repository.GetByVirtualKeyIdAsync(virtualKeyId, cancellationToken); - return history - .Where(h => h.Timestamp >= startDate && h.Timestamp <= endDate) - .OrderBy(h => h.Timestamp) - .ToList(); - } - - /// - /// Maps a ModelProviderMapping entity to a ModelProviderMappingDto - /// - /// The entity to map - /// The mapped DTO - public static ModelProviderMappingDto ToDto(this ConduitLLM.Configuration.Entities.ModelProviderMapping mapping) - { - if (mapping == null) - { - throw new ArgumentNullException(nameof(mapping)); - } - - return new ConduitLLM.Configuration.DTOs.ModelProviderMappingDto - { - Id = mapping.Id, - ModelAlias = mapping.ModelAlias, - ProviderModelId = mapping.ProviderModelId, - ProviderId = mapping.ProviderId, - Provider = mapping.Provider != null ? new ProviderReferenceDto - { - Id = mapping.Provider.Id, - ProviderType = mapping.Provider.ProviderType, - DisplayName = mapping.Provider.ProviderName, - IsEnabled = mapping.Provider.IsEnabled - } : null, - ModelProviderTypeAssociationId = mapping.ModelProviderTypeAssociationId, - Priority = 0, // Default priority if not available in entity - IsEnabled = mapping.IsEnabled, - CreatedAt = mapping.CreatedAt, - UpdatedAt = mapping.UpdatedAt, - Notes = null, // Not available in entity - Capabilities = mapping.ModelProviderTypeAssociation?.Model != null ? new ConduitLLM.Configuration.DTOs.ModelCapabilitiesDto - { - SupportsVision = mapping.ModelProviderTypeAssociation.Model.SupportsVision, - SupportsImageGeneration = mapping.ModelProviderTypeAssociation.Model.SupportsImageGeneration, - SupportsVideoGeneration = mapping.ModelProviderTypeAssociation.Model.SupportsVideoGeneration, - SupportsEmbeddings = mapping.ModelProviderTypeAssociation.Model.SupportsEmbeddings, - SupportsChat = mapping.ModelProviderTypeAssociation.Model.SupportsChat, - SupportsFunctionCalling = mapping.ModelProviderTypeAssociation.Model.SupportsFunctionCalling, - SupportsStreaming = mapping.ModelProviderTypeAssociation.Model.SupportsStreaming, - MaxInputTokens = mapping.ModelProviderTypeAssociation.Model.MaxInputTokens, - MaxOutputTokens = mapping.ModelProviderTypeAssociation.Model.MaxOutputTokens - } : null - }; + // Use the repository's DB-level filtered query instead of loading all history then filtering in memory + var history = await repository.GetByVirtualKeyAndDateRangeAsync(virtualKeyId, startDate, endDate, cancellationToken); + return history.OrderBy(h => h.Timestamp).ToList(); } - /// - /// Maps a ModelProviderMappingDto to a ModelProviderMapping entity - /// - /// The DTO to map - /// The mapped entity - public static ConduitLLM.Configuration.Entities.ModelProviderMapping ToEntity(this ModelProviderMappingDto dto) - { - if (dto == null) - { - throw new ArgumentNullException(nameof(dto)); - } - - return new ConduitLLM.Configuration.Entities.ModelProviderMapping - { - Id = dto.Id, - ModelAlias = dto.ModelAlias, - ProviderModelId = dto.ProviderModelId, - ProviderId = dto.ProviderId, - ModelProviderTypeAssociationId = dto.ModelProviderTypeAssociationId, - IsEnabled = dto.IsEnabled, - CreatedAt = dto.CreatedAt, - UpdatedAt = dto.UpdatedAt - }; - } - - - /// diff --git a/Services/ConduitLLM.Admin/Extensions/SecurityOptionsExtensions.cs b/Services/ConduitLLM.Admin/Extensions/SecurityOptionsExtensions.cs index 34dea2bfd..0be959a65 100644 --- a/Services/ConduitLLM.Admin/Extensions/SecurityOptionsExtensions.cs +++ b/Services/ConduitLLM.Admin/Extensions/SecurityOptionsExtensions.cs @@ -1,84 +1,25 @@ -using ConduitLLM.Admin.Options; +// Re-export the shared security options extension methods for Admin API +// This file is a facade that delegates to the shared ConduitLLM.Security library +using ConduitLLM.Security.Options; namespace ConduitLLM.Admin.Extensions { /// - /// Extension methods for configuring security options + /// Extension methods for configuring Admin security options. + /// Delegates to the shared ConduitLLM.Security.Options.SecurityOptionsExtensions. /// - public static class SecurityOptionsExtensions + public static class AdminSecurityOptionsExtensions { /// - /// Configures security options from environment variables + /// Configures Admin security options from environment variables. + /// This is a facade method that delegates to the shared implementation. /// - public static IServiceCollection ConfigureAdminSecurityOptions(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection ConfigureAdminSecurityOptions( + this IServiceCollection services, + IConfiguration configuration) { - services.Configure(options => - { - // IP Filtering - options.IpFiltering.Enabled = configuration.GetValue("CONDUIT_ADMIN_IP_FILTERING_ENABLED", false); - options.IpFiltering.Mode = configuration["CONDUIT_ADMIN_IP_FILTER_MODE"] ?? "permissive"; - options.IpFiltering.AllowPrivateIps = configuration.GetValue("CONDUIT_ADMIN_IP_FILTER_ALLOW_PRIVATE", true); - - // Parse whitelist and blacklist from comma-separated values - var whitelist = configuration["CONDUIT_ADMIN_IP_FILTER_WHITELIST"]; - if (!string.IsNullOrWhiteSpace(whitelist)) - { - options.IpFiltering.Whitelist = whitelist.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()).ToList(); - } - - var blacklist = configuration["CONDUIT_ADMIN_IP_FILTER_BLACKLIST"]; - if (!string.IsNullOrWhiteSpace(blacklist)) - { - options.IpFiltering.Blacklist = blacklist.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()).ToList(); - } - - // Rate Limiting - options.RateLimiting.Enabled = configuration.GetValue("CONDUIT_ADMIN_RATE_LIMITING_ENABLED", false); - options.RateLimiting.MaxRequests = configuration.GetValue("CONDUIT_ADMIN_RATE_LIMIT_MAX_REQUESTS", 100); - options.RateLimiting.WindowSeconds = configuration.GetValue("CONDUIT_ADMIN_RATE_LIMIT_WINDOW_SECONDS", 60); - - var rateLimitExcluded = configuration["CONDUIT_ADMIN_RATE_LIMIT_EXCLUDED_PATHS"]; - if (!string.IsNullOrWhiteSpace(rateLimitExcluded)) - { - options.RateLimiting.ExcludedPaths = rateLimitExcluded.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()).ToList(); - } - - // Failed Authentication Protection - options.FailedAuth.Enabled = configuration.GetValue("CONDUIT_ADMIN_IP_BANNING_ENABLED", true); - options.FailedAuth.MaxAttempts = configuration.GetValue("CONDUIT_ADMIN_MAX_FAILED_AUTH_ATTEMPTS", 5); - options.FailedAuth.BanDurationMinutes = configuration.GetValue("CONDUIT_ADMIN_AUTH_BAN_DURATION_MINUTES", 30); - - // Distributed Tracking (shared with WebAdmin) - options.UseDistributedTracking = configuration.GetValue("CONDUIT_SECURITY_USE_DISTRIBUTED_TRACKING", true); - - // Security Headers - var headers = options.Headers; - - // X-Content-Type-Options - headers.XContentTypeOptions = configuration.GetValue("CONDUIT_ADMIN_SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED", true); - - // X-XSS-Protection - headers.XXssProtection = configuration.GetValue("CONDUIT_ADMIN_SECURITY_HEADERS_X_XSS_PROTECTION_ENABLED", true); - - // HSTS - headers.Hsts.Enabled = configuration.GetValue("CONDUIT_ADMIN_SECURITY_HEADERS_HSTS_ENABLED", true); - headers.Hsts.MaxAge = configuration.GetValue("CONDUIT_ADMIN_SECURITY_HEADERS_HSTS_MAX_AGE", 31536000); - - // API Authentication - options.ApiAuth.ApiKeyHeader = configuration["CONDUIT_ADMIN_API_KEY_HEADER"] ?? "X-API-Key"; - - var altHeaders = configuration["CONDUIT_ADMIN_API_KEY_ALT_HEADERS"]; - if (!string.IsNullOrWhiteSpace(altHeaders)) - { - options.ApiAuth.AlternativeHeaders = altHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()).ToList(); - } - }); - - return services; + // Delegate to the shared implementation + return SecurityOptionsExtensions.ConfigureAdminSecurityOptions(services, configuration); } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Extensions/ServiceCollectionExtensions.cs b/Services/ConduitLLM.Admin/Extensions/ServiceCollectionExtensions.cs index 2d10d25ef..5a6f3c76d 100644 --- a/Services/ConduitLLM.Admin/Extensions/ServiceCollectionExtensions.cs +++ b/Services/ConduitLLM.Admin/Extensions/ServiceCollectionExtensions.cs @@ -1,21 +1,14 @@ using ConduitLLM.Admin.Interfaces; -using ConduitLLM.Admin.Options; using ConduitLLM.Admin.Security; using ConduitLLM.Admin.Services; -using ConduitLLM.Configuration; // For ConduitDbContext -using ConduitLLM.Core.Extensions; // For AddMediaServices extension method -using ConduitLLM.Core.Interfaces; // For IVirtualKeyCache and ILLMClientFactory -using ConduitLLM.Configuration.Interfaces; // For repository interfaces -using ConduitLLM.Configuration.Repositories; // For repository interfaces +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Options; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Security.Authorization; +using ConduitLLM.Security.Options; -using MassTransit; // For IPublishEndpoint using Microsoft.AspNetCore.Authorization; -using Microsoft.EntityFrameworkCore; // For IDbContextFactory -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Options; -using StackExchange.Redis; namespace ConduitLLM.Admin.Extensions; @@ -35,18 +28,10 @@ public static IServiceCollection AddAdminServices(this IServiceCollection servic // Configure security options from environment variables services.ConfigureAdminSecurityOptions(configuration); - // Register security service as singleton with factory to handle scoped dependencies - services.AddSingleton(serviceProvider => - { - var options = serviceProvider.GetRequiredService>(); - var config = serviceProvider.GetRequiredService(); - var logger = serviceProvider.GetRequiredService>(); - var memoryCache = serviceProvider.GetRequiredService(); - var distributedCache = serviceProvider.GetService(); // Optional - var serviceScopeFactory = serviceProvider.GetRequiredService(); - - return new SecurityService(options, config, logger, memoryCache, distributedCache, serviceScopeFactory); - }); + // Register security service as singleton for both shared and admin-specific interfaces + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); // Add memory cache if not already registered services.AddMemoryCache(); @@ -60,127 +45,88 @@ public static IServiceCollection AddAdminServices(this IServiceCollection servic // Register authorization policy for master key services.AddSingleton(); + + // Register health key authorization handler (shared from ConduitLLM.Security) + services.AddSingleton(); + services.AddAuthorization(options => { // Define the MasterKeyPolicy options.AddPolicy("MasterKeyPolicy", policy => policy.Requirements.Add(new MasterKeyRequirement())); - + // Set MasterKeyPolicy as the default policy for all controllers // This ensures any controller with [Authorize] will use MasterKeyPolicy by default options.DefaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() .AddRequirements(new MasterKeyRequirement()) .Build(); - }); - // Register AdminVirtualKeyService with optional cache and event publishing dependencies - services.AddScoped(serviceProvider => - { - var virtualKeyRepository = serviceProvider.GetRequiredService(); - var spendHistoryRepository = serviceProvider.GetRequiredService(); - var groupRepository = serviceProvider.GetRequiredService(); - var cache = serviceProvider.GetService(); // Optional - null if not registered - var publishEndpoint = serviceProvider.GetService(); // Optional - null if MassTransit not configured - var logger = serviceProvider.GetRequiredService>(); - var modelProviderMappingRepository = serviceProvider.GetRequiredService(); - var modelCapabilityService = serviceProvider.GetRequiredService(); - var dbContextFactory = serviceProvider.GetRequiredService>(); - var mediaLifecycleService = serviceProvider.GetService(); // Optional - null if not configured - - return new AdminVirtualKeyService(virtualKeyRepository, spendHistoryRepository, groupRepository, cache, publishEndpoint, logger, modelProviderMappingRepository, modelCapabilityService, dbContextFactory, mediaLifecycleService); - }); - // Register AdminModelProviderMappingService with optional event publishing dependency - services.AddScoped(serviceProvider => - { - var mappingRepository = serviceProvider.GetRequiredService(); - var credentialRepository = serviceProvider.GetRequiredService(); - var modelRepository = serviceProvider.GetRequiredService(); - var publishEndpoint = serviceProvider.GetService(); // Optional - null if MassTransit not configured - var logger = serviceProvider.GetRequiredService>(); - - return new AdminModelProviderMappingService(mappingRepository, credentialRepository, modelRepository, publishEndpoint, logger); + // Add policy for health endpoint access - allows private network OR valid health key + options.AddPolicy("HealthMonitoring", policy => + { + policy.Requirements.Add(new HealthKeyRequirement()); + }); }); + + // Register AdminVirtualKeyService (optional deps use default parameter values) + services.AddScoped(); + // Register AdminModelProviderMappingService (optional deps use default parameter values) + services.AddScoped(); // Register Analytics services services.AddSingleton(); services.AddScoped(); - // Register AdminIpFilterService with optional event publishing dependency - services.AddScoped(serviceProvider => - { - var ipFilterRepository = serviceProvider.GetRequiredService(); - var globalSettingRepository = serviceProvider.GetRequiredService(); - var ipFilterOptions = serviceProvider.GetRequiredService>(); - var publishEndpoint = serviceProvider.GetService(); // Optional - null if MassTransit not configured - var logger = serviceProvider.GetRequiredService>(); - - return new AdminIpFilterService(ipFilterRepository, globalSettingRepository, ipFilterOptions, publishEndpoint, logger); - }); + // Register AdminIpFilterService (optional deps use default parameter values) + services.AddScoped(); services.AddScoped(); services.AddScoped(); - // Register AdminGlobalSettingService with optional event publishing dependency - services.AddScoped(serviceProvider => - { - var globalSettingRepository = serviceProvider.GetRequiredService(); - var publishEndpoint = serviceProvider.GetService(); // Optional - null if MassTransit not configured - var logger = serviceProvider.GetRequiredService>(); - - return new AdminGlobalSettingService(globalSettingRepository, publishEndpoint, logger); - }); - // Register AdminModelCostService with optional event publishing dependency - services.AddScoped(serviceProvider => + // Register AdminGlobalSettingService (optional deps use default parameter values) + services.AddScoped(); + // Register AdminModelCostService (optional deps use default parameter values) + services.AddScoped(); + + // Register cost calculation dependencies with caching decorator pattern + services.AddScoped(); + services.AddScoped(provider => { - var modelCostRepository = serviceProvider.GetRequiredService(); - var requestLogRepository = serviceProvider.GetRequiredService(); - var dbContextFactory = serviceProvider.GetRequiredService>(); - var publishEndpoint = serviceProvider.GetService(); // Optional - null if MassTransit not configured - var logger = serviceProvider.GetRequiredService>(); - - return new AdminModelCostService(modelCostRepository, requestLogRepository, dbContextFactory, publishEndpoint, logger); + var innerService = provider.GetRequiredService(); + var cacheManager = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + return new ConduitLLM.Core.Services.CachedModelCostService(innerService, cacheManager, logger); }); - - // Register cost calculation dependencies - services.AddScoped(); services.AddScoped(); // Register refund service services.AddScoped(); - // Register media management service (requires IMediaLifecycleService to be registered) + // Register media management service (requires IMediaLifecycleService and IMediaStorageService to be registered) services.AddScoped(serviceProvider => { var mediaRepository = serviceProvider.GetRequiredService(); var mediaLifecycleService = serviceProvider.GetService(); + var storageService = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); - + // Only register if media lifecycle service is available if (mediaLifecycleService == null) { throw new InvalidOperationException("IMediaLifecycleService must be registered to use AdminMediaService"); } - - return new AdminMediaService(mediaRepository, mediaLifecycleService, logger); - }); - - // Register database-aware LLM client factory (must be registered before discovery service) - services.AddScoped(); - // Configure HttpClient for discovery providers - services.AddHttpClient("DiscoveryProviders", client => - { - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM-Admin/1.0"); + return new AdminMediaService(mediaRepository, mediaLifecycleService, storageService, logger); }); - // Model discovery providers have been removed - capabilities now come from ModelProviderMapping + // ILLMClientFactory is registered via AddProviderServices() in the shared Providers extension + // Do not duplicate here โ€” the shared registration is the single source of truth + + // Register shared HTTP clients (DiscoveryProviders, ImageDownload, Exa, Tavily) + services.AddSharedHttpClients(); // Register Media Services using shared configuration from Core services.AddMediaServices(configuration); - // Register SignalR admin notification service - services.AddScoped(); - // Register LLM cache management service (simple database + event publishing) services.AddScoped(); @@ -190,11 +136,6 @@ public static IServiceCollection AddAdminServices(this IServiceCollection servic services.AddScoped(); services.AddScoped(); - // NOTE: ICacheManagementService registration is commented out because it requires - // cache infrastructure services (ICacheRegistry, ICacheStatisticsCollector, ICachePolicyEngine) - // that are not currently implemented. General cache management endpoints will return 501. - // services.AddScoped(); - // Register billing audit service for comprehensive billing event tracking - with leader election services.AddSingleton(); services.AddLeaderElectedHostedService( @@ -206,7 +147,7 @@ public static IServiceCollection AddAdminServices(this IServiceCollection servic services.AddScoped(); services.AddScoped(); - // Register cached pricing rules service for parsed configuration caching + // Register cached pricing rules service for parsed configuration caching (uses ICacheManager) services.AddSingleton(); // Register pricing audit service for rules-based pricing event tracking - with leader election @@ -250,7 +191,7 @@ public static IServiceCollection AddAdminServices(this IServiceCollection servic options.AddPolicy("AdminCorsPolicy", policy => { var allowedOrigins = configuration.GetSection("AdminApi:AllowedOrigins").Get(); - if (allowedOrigins != null && allowedOrigins.Length == 0) + if (allowedOrigins != null && allowedOrigins.Length > 0) { policy.WithOrigins(allowedOrigins) .AllowAnyMethod() diff --git a/Services/ConduitLLM.Admin/Extensions/WebApplicationExtensions.cs b/Services/ConduitLLM.Admin/Extensions/WebApplicationExtensions.cs index a1d074252..7b7d4b2d9 100644 --- a/Services/ConduitLLM.Admin/Extensions/WebApplicationExtensions.cs +++ b/Services/ConduitLLM.Admin/Extensions/WebApplicationExtensions.cs @@ -1,4 +1,6 @@ using ConduitLLM.Admin.Middleware; +using ConduitLLM.Core.Middleware; +using ConduitLLM.Security.Middleware; namespace ConduitLLM.Admin.Extensions; @@ -14,18 +16,31 @@ public static class WebApplicationExtensions /// The web application for chaining public static WebApplication UseAdminMiddleware(this WebApplication app) { + // Enable request body buffering so it can be re-read for error diagnostics + app.Use(async (context, next) => + { + context.Request.EnableBuffering(); + await next(); + }); + + // Add correlation ID middleware (earliest โ€” establishes correlation context for all downstream middleware) + app.UseCorrelationId(); + // Add CORS middleware app.UseCors("AdminCorsPolicy"); // Add security headers middleware app.UseAdminSecurityHeaders(); - // Add unified security middleware (replaces AdminAuthenticationMiddleware) + // Add unified security middleware (authentication, rate limiting, IP filtering) app.UseAdminSecurity(); // Add Ephemeral Master Key cleanup middleware app.UseMiddleware(); + // Add global exception handling middleware (catches exceptions from downstream middleware and controllers) + app.UseAdminExceptionHandling(); + // Add HTTP metrics middleware app.UseMiddleware(); diff --git a/Services/ConduitLLM.Admin/Hubs/AdminNotificationHub.cs b/Services/ConduitLLM.Admin/Hubs/AdminNotificationHub.cs index 9c1384557..835b41071 100644 --- a/Services/ConduitLLM.Admin/Hubs/AdminNotificationHub.cs +++ b/Services/ConduitLLM.Admin/Hubs/AdminNotificationHub.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Admin.Metrics; namespace ConduitLLM.Admin.Hubs { @@ -14,22 +15,18 @@ public class AdminNotificationHub : Hub, IAdminNotificationHub { private readonly ILogger _logger; private readonly IAdminVirtualKeyService _virtualKeyService; - private readonly IAdminNotificationService _notificationService; /// /// Initializes a new instance of the class. /// /// Logger instance. /// Virtual key service for key management notifications. - /// Notification service for administrative alerts. public AdminNotificationHub( ILogger logger, - IAdminVirtualKeyService virtualKeyService, - IAdminNotificationService notificationService) + IAdminVirtualKeyService virtualKeyService) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _virtualKeyService = virtualKeyService ?? throw new ArgumentNullException(nameof(virtualKeyService)); - _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); } /// @@ -38,12 +35,14 @@ public AdminNotificationHub( /// A task representing the asynchronous operation. public override async Task OnConnectedAsync() { - _logger.LogInformation("Admin client connected to AdminNotificationHub: {ConnectionId}", Context.ConnectionId); - + _logger.LogDebug("Admin client connected to AdminNotificationHub: {ConnectionId}", Context.ConnectionId); + AdminSignalRMetrics.Connections.WithLabels("connected").Inc(); + AdminSignalRMetrics.ActiveConnections.Inc(); + // Add to admin group for receiving broadcast notifications await Groups.AddToGroupAsync(Context.ConnectionId, "admin"); - - + + await base.OnConnectedAsync(); } @@ -54,16 +53,21 @@ public override async Task OnConnectedAsync() /// A task representing the asynchronous operation. public override async Task OnDisconnectedAsync(Exception? exception) { - _logger.LogInformation("Admin client disconnected from AdminNotificationHub: {ConnectionId}", Context.ConnectionId); - + _logger.LogDebug("Admin client disconnected from AdminNotificationHub: {ConnectionId}", Context.ConnectionId); + AdminSignalRMetrics.ActiveConnections.Dec(); + if (exception != null) + AdminSignalRMetrics.Connections.WithLabels("disconnected_error").Inc(); + else + AdminSignalRMetrics.Connections.WithLabels("disconnected").Inc(); + // Remove from admin group await Groups.RemoveFromGroupAsync(Context.ConnectionId, "admin"); - + if (exception != null) { _logger.LogError(exception, "Admin client disconnected due to error"); } - + await base.OnDisconnectedAsync(exception); } @@ -87,13 +91,15 @@ public async Task SubscribeToVirtualKey(int virtualKeyId) var groupName = $"admin-vkey-{virtualKeyId}"; await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - _logger.LogInformation("Admin subscribed to virtual key {VirtualKeyId} notifications", virtualKeyId); - + _logger.LogDebug("Admin subscribed to virtual key {VirtualKeyId} notifications", virtualKeyId); + AdminSignalRMetrics.Subscriptions.WithLabels("virtualkey", "subscribe", "success").Inc(); + await Clients.Caller.SendAsync("SubscribedToVirtualKey", virtualKeyId); } catch (Exception ex) { _logger.LogError(ex, "Error subscribing to virtual key {VirtualKeyId}", virtualKeyId); + AdminSignalRMetrics.Subscriptions.WithLabels("virtualkey", "subscribe", "failure").Inc(); await Clients.Caller.SendAsync("Error", new { message = "Failed to subscribe to virtual key notifications" }); } } @@ -110,13 +116,15 @@ public async Task UnsubscribeFromVirtualKey(int virtualKeyId) var groupName = $"admin-vkey-{virtualKeyId}"; await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); - _logger.LogInformation("Admin unsubscribed from virtual key {VirtualKeyId} notifications", virtualKeyId); - + _logger.LogDebug("Admin unsubscribed from virtual key {VirtualKeyId} notifications", virtualKeyId); + AdminSignalRMetrics.Subscriptions.WithLabels("virtualkey", "unsubscribe", "success").Inc(); + await Clients.Caller.SendAsync("UnsubscribedFromVirtualKey", virtualKeyId); } catch (Exception ex) { _logger.LogError(ex, "Error unsubscribing from virtual key {VirtualKeyId}", virtualKeyId); + AdminSignalRMetrics.Subscriptions.WithLabels("virtualkey", "unsubscribe", "failure").Inc(); await Clients.Caller.SendAsync("Error", new { message = "Failed to unsubscribe from virtual key notifications" }); } } @@ -133,15 +141,17 @@ public async Task SubscribeToProvider(int providerId) var groupName = $"admin-provider-{providerId}"; await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - _logger.LogInformation("Admin subscribed to provider {ProviderId} notifications", providerId); - + _logger.LogDebug("Admin subscribed to provider {ProviderId} notifications", providerId); + AdminSignalRMetrics.Subscriptions.WithLabels("provider", "subscribe", "success").Inc(); + // Provider health tracking has been removed - + await Clients.Caller.SendAsync("SubscribedToProvider", providerId); } catch (Exception ex) { _logger.LogError(ex, "Error subscribing to provider {ProviderId}", providerId); + AdminSignalRMetrics.Subscriptions.WithLabels("provider", "subscribe", "failure").Inc(); await Clients.Caller.SendAsync("Error", new { message = "Failed to subscribe to provider notifications" }); } } @@ -158,13 +168,15 @@ public async Task UnsubscribeFromProvider(int providerId) var groupName = $"admin-provider-{providerId}"; await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); - _logger.LogInformation("Admin unsubscribed from provider {ProviderId} notifications", providerId); - + _logger.LogDebug("Admin unsubscribed from provider {ProviderId} notifications", providerId); + AdminSignalRMetrics.Subscriptions.WithLabels("provider", "unsubscribe", "success").Inc(); + await Clients.Caller.SendAsync("UnsubscribedFromProvider", providerId); } catch (Exception ex) { _logger.LogError(ex, "Error unsubscribing from provider {ProviderId}", providerId); + AdminSignalRMetrics.Subscriptions.WithLabels("provider", "unsubscribe", "failure").Inc(); await Clients.Caller.SendAsync("Error", new { message = "Failed to unsubscribe from provider notifications" }); } } diff --git a/Services/ConduitLLM.Admin/Interfaces/ISecurityService.cs b/Services/ConduitLLM.Admin/Interfaces/ISecurityService.cs index e98413167..b6047667f 100644 --- a/Services/ConduitLLM.Admin/Interfaces/ISecurityService.cs +++ b/Services/ConduitLLM.Admin/Interfaces/ISecurityService.cs @@ -1,54 +1,16 @@ +using SharedSecurity = ConduitLLM.Security.Interfaces; + namespace ConduitLLM.Admin.Interfaces { /// - /// Unified security service for Admin API + /// Admin-specific security service interface. + /// Extends the shared security service with master key validation. /// - public interface ISecurityService + public interface IAdminSecurityService : SharedSecurity.ISecurityService { /// - /// Checks if a request is allowed based on all security rules - /// - Task IsRequestAllowedAsync(HttpContext context); - - /// - /// Records a failed authentication attempt - /// - Task RecordFailedAuthAsync(string ipAddress); - - /// - /// Clears failed authentication attempts for an IP - /// - Task ClearFailedAuthAttemptsAsync(string ipAddress); - - /// - /// Checks if an IP is banned due to failed authentication - /// - Task IsIpBannedAsync(string ipAddress); - - /// - /// Validates the API key + /// Validates the API key against the configured master key /// bool ValidateApiKey(string providedKey); } - - /// - /// Result of a security check - /// - public class SecurityCheckResult - { - /// - /// Whether the request is allowed - /// - public bool IsAllowed { get; set; } - - /// - /// Reason for denial if not allowed - /// - public string Reason { get; set; } = ""; - - /// - /// HTTP status code to return - /// - public int? StatusCode { get; set; } - } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Metrics/AdminAuthMetrics.cs b/Services/ConduitLLM.Admin/Metrics/AdminAuthMetrics.cs new file mode 100644 index 000000000..eb6a50554 --- /dev/null +++ b/Services/ConduitLLM.Admin/Metrics/AdminAuthMetrics.cs @@ -0,0 +1,22 @@ +using ConduitLLM.Core.Metrics; +using Prometheus; + +namespace ConduitLLM.Admin.Metrics +{ + /// + /// Prometheus metrics for Admin API authentication operations. + /// Delegates to shared with "admin" prefix. + /// + public static class AdminAuthMetrics + { + private static readonly AuthMetrics Instance = new("admin"); + + public static Counter AuthAttempts => Instance.AuthAttempts; + public static Histogram AuthDuration => Instance.AuthDuration; + public static Counter AuthFailures => Instance.AuthFailures; + + public static void RecordSuccess(string scheme) => Instance.RecordSuccess(scheme); + public static void RecordFailure(string scheme, string reason) => Instance.RecordFailure(scheme, reason); + public static void RecordDuration(string scheme, double durationSeconds) => Instance.RecordDuration(scheme, durationSeconds); + } +} diff --git a/Services/ConduitLLM.Admin/Metrics/AdminCacheMetrics.cs b/Services/ConduitLLM.Admin/Metrics/AdminCacheMetrics.cs new file mode 100644 index 000000000..f6ad3564a --- /dev/null +++ b/Services/ConduitLLM.Admin/Metrics/AdminCacheMetrics.cs @@ -0,0 +1,25 @@ +using ConduitLLM.Core.Metrics; +using Prometheus; + +namespace ConduitLLM.Admin.Metrics +{ + /// + /// Prometheus metrics for Admin API cache operations. + /// Delegates to shared with "admin" prefix. + /// + public static class AdminCacheMetrics + { + private static readonly CacheMetrics Instance = new("admin"); + + public static Counter CacheLookups => Instance.CacheLookups; + public static Histogram CacheLatency => Instance.CacheLatency; + public static Counter CacheInvalidations => Instance.CacheInvalidations; + public static Counter CacheErrors => Instance.CacheErrors; + + public static void RecordHit(string cacheName) => Instance.RecordHit(cacheName); + public static void RecordMiss(string cacheName) => Instance.RecordMiss(cacheName); + public static void RecordLatency(string cacheName, string operation, double durationSeconds) => Instance.RecordLatency(cacheName, operation, durationSeconds); + public static void RecordInvalidation(string cacheName, string reason = "explicit") => Instance.RecordInvalidation(cacheName, reason); + public static void RecordError(string cacheName, string operation) => Instance.RecordError(cacheName, operation); + } +} diff --git a/Services/ConduitLLM.Admin/Metrics/AdminMediaCleanupMetrics.cs b/Services/ConduitLLM.Admin/Metrics/AdminMediaCleanupMetrics.cs new file mode 100644 index 000000000..12803e9aa --- /dev/null +++ b/Services/ConduitLLM.Admin/Metrics/AdminMediaCleanupMetrics.cs @@ -0,0 +1,63 @@ +using Prometheus; + +namespace ConduitLLM.Admin.Metrics +{ + /// + /// Prometheus metrics for media cleanup operations. + /// Tracks files deleted, bytes freed, duration, and errors. + /// + public static class AdminMediaCleanupMetrics + { + /// + /// Total cleanup cycles by status. + /// + public static readonly Counter CleanupCycles = Prometheus.Metrics + .CreateCounter("conduit_admin_media_cleanup_cycles_total", "Total media cleanup cycles", + new CounterConfiguration + { + LabelNames = new[] { "status" } // status: completed, failed, cancelled, no_groups + }); + + /// + /// Total files deleted during cleanup. + /// + public static readonly Counter FilesDeleted = Prometheus.Metrics + .CreateCounter("conduit_admin_media_cleanup_files_deleted_total", "Total files deleted during cleanup"); + + /// + /// Total bytes freed during cleanup. + /// + public static readonly Counter BytesFreed = Prometheus.Metrics + .CreateCounter("conduit_admin_media_cleanup_bytes_freed_total", "Total bytes freed during cleanup"); + + /// + /// Cleanup cycle duration in seconds. + /// + public static readonly Histogram CleanupDuration = Prometheus.Metrics + .CreateHistogram("conduit_admin_media_cleanup_duration_seconds", "Media cleanup cycle duration", + new HistogramConfiguration + { + Buckets = Histogram.ExponentialBuckets(1, 2, 12) // 1s to ~4096s + }); + + /// + /// Total cleanup errors. + /// + public static readonly Counter CleanupErrors = Prometheus.Metrics + .CreateCounter("conduit_admin_media_cleanup_errors_total", "Total media cleanup errors", + new CounterConfiguration + { + LabelNames = new[] { "error_type" } // error_type: group_processing, batch_deletion, storage + }); + + /// + /// Number of virtual key groups processed per cleanup cycle. + /// + public static readonly Histogram GroupsProcessed = Prometheus.Metrics + .CreateHistogram("conduit_admin_media_cleanup_groups_processed", "Groups processed per cleanup cycle", + new HistogramConfiguration + { + Buckets = Histogram.LinearBuckets(0, 5, 20) // 0 to 100 in steps of 5 + }); + } +} diff --git a/Services/ConduitLLM.Admin/Metrics/AdminRequestMetrics.cs b/Services/ConduitLLM.Admin/Metrics/AdminRequestMetrics.cs new file mode 100644 index 000000000..7675c10d5 --- /dev/null +++ b/Services/ConduitLLM.Admin/Metrics/AdminRequestMetrics.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; + +namespace ConduitLLM.Admin.Metrics +{ + /// + /// Distributed tracing spans for high-value Admin operations. + /// Uses ActivitySource for OpenTelemetry-compatible distributed tracing. + /// + public static class AdminRequestMetrics + { + /// + /// ActivitySource for Admin API operations. + /// + public static readonly ActivitySource ActivitySource = new("ConduitLLM.Admin.Requests", "1.0.0"); + + /// + /// Starts an activity for a provider test operation. + /// + public static Activity? StartProviderTestActivity(string providerType, int providerId) + { + return ActivitySource.StartActivity("admin.provider.test", ActivityKind.Client, + default(ActivityContext), + new[] + { + new KeyValuePair("admin.provider_type", providerType), + new KeyValuePair("admin.provider_id", providerId), + new KeyValuePair("admin.operation", "provider_test") + }); + } + + /// + /// Starts an activity for a virtual key operation. + /// + public static Activity? StartVirtualKeyActivity(string operation, int? keyId = null) + { + var tags = new List> + { + new("admin.operation", $"virtualkey_{operation}") + }; + if (keyId.HasValue) + tags.Add(new("admin.virtualkey_id", keyId.Value)); + + return ActivitySource.StartActivity($"admin.virtualkey.{operation}", ActivityKind.Server, + default(ActivityContext), tags); + } + + /// + /// Starts an activity for a media cleanup cycle. + /// + public static Activity? StartMediaCleanupActivity(string instanceId, bool dryRun) + { + return ActivitySource.StartActivity("admin.media.cleanup", ActivityKind.Internal, + default(ActivityContext), + new[] + { + new KeyValuePair("admin.instance_id", instanceId), + new KeyValuePair("admin.dry_run", dryRun), + new KeyValuePair("admin.operation", "media_cleanup") + }); + } + + /// + /// Starts an activity for a CSV import/export operation. + /// + public static Activity? StartCsvActivity(string operation, string entityType) + { + return ActivitySource.StartActivity($"admin.csv.{operation}", ActivityKind.Server, + default(ActivityContext), + new[] + { + new KeyValuePair("admin.csv_operation", operation), + new KeyValuePair("admin.entity_type", entityType), + new KeyValuePair("admin.operation", $"csv_{operation}") + }); + } + + /// + /// Starts an activity for a configuration change. + /// + public static Activity? StartConfigurationActivity(string entityType, string changeType) + { + return ActivitySource.StartActivity($"admin.config.{changeType}", ActivityKind.Server, + default(ActivityContext), + new[] + { + new KeyValuePair("admin.entity_type", entityType), + new KeyValuePair("admin.change_type", changeType), + new KeyValuePair("admin.operation", "configuration_change") + }); + } + } +} diff --git a/Services/ConduitLLM.Admin/Metrics/AdminSecurityMetrics.cs b/Services/ConduitLLM.Admin/Metrics/AdminSecurityMetrics.cs new file mode 100644 index 000000000..666791213 --- /dev/null +++ b/Services/ConduitLLM.Admin/Metrics/AdminSecurityMetrics.cs @@ -0,0 +1,45 @@ +using Prometheus; + +namespace ConduitLLM.Admin.Metrics +{ + /// + /// Prometheus metrics for Admin API security middleware. + /// Tracks authentication failures, rate limit hits, and access denials. + /// + public static class AdminSecurityMetrics + { + /// + /// Total security violations by type. + /// + public static readonly Counter SecurityViolations = Prometheus.Metrics + .CreateCounter("conduit_admin_security_violations_total", "Total security violations", + new CounterConfiguration + { + LabelNames = new[] { "type" } // type: auth_failure, rate_limit, access_denied, blocked + }); + + /// + /// Total requests allowed through security middleware. + /// + public static readonly Counter RequestsAllowed = Prometheus.Metrics + .CreateCounter("conduit_admin_security_requests_allowed_total", "Total requests allowed through security"); + + // Convenience methods + + /// Records an authentication failure (401). + public static void RecordAuthFailure() + => SecurityViolations.WithLabels("auth_failure").Inc(); + + /// Records a rate limit hit (429). + public static void RecordRateLimitHit() + => SecurityViolations.WithLabels("rate_limit").Inc(); + + /// Records an access denial (403). + public static void RecordAccessDenied() + => SecurityViolations.WithLabels("access_denied").Inc(); + + /// Records an unspecified security block. + public static void RecordBlocked() + => SecurityViolations.WithLabels("blocked").Inc(); + } +} diff --git a/Services/ConduitLLM.Admin/Metrics/AdminSignalRMetrics.cs b/Services/ConduitLLM.Admin/Metrics/AdminSignalRMetrics.cs new file mode 100644 index 000000000..c21399af5 --- /dev/null +++ b/Services/ConduitLLM.Admin/Metrics/AdminSignalRMetrics.cs @@ -0,0 +1,37 @@ +using Prometheus; + +namespace ConduitLLM.Admin.Metrics +{ + /// + /// Prometheus metrics for Admin SignalR hub operations. + /// Tracks connections, subscriptions, and message delivery. + /// + public static class AdminSignalRMetrics + { + /// + /// Total SignalR connections by status. + /// + public static readonly Counter Connections = Prometheus.Metrics + .CreateCounter("conduit_admin_signalr_connections_total", "Total SignalR connections", + new CounterConfiguration + { + LabelNames = new[] { "event" } // event: connected, disconnected, disconnected_error + }); + + /// + /// Currently active SignalR connections. + /// + public static readonly Gauge ActiveConnections = Prometheus.Metrics + .CreateGauge("conduit_admin_signalr_active_connections", "Active SignalR connections"); + + /// + /// Total subscription operations by type and status. + /// + public static readonly Counter Subscriptions = Prometheus.Metrics + .CreateCounter("conduit_admin_signalr_subscriptions_total", "Total subscription operations", + new CounterConfiguration + { + LabelNames = new[] { "type", "action", "status" } // type: virtualkey, provider; action: subscribe, unsubscribe; status: success, failure + }); + } +} diff --git a/Services/ConduitLLM.Admin/Middleware/AdminAuthenticationMiddleware.cs b/Services/ConduitLLM.Admin/Middleware/AdminAuthenticationMiddleware.cs deleted file mode 100644 index dd842cb5b..000000000 --- a/Services/ConduitLLM.Admin/Middleware/AdminAuthenticationMiddleware.cs +++ /dev/null @@ -1,88 +0,0 @@ -using ConduitLLM.Core.Extensions; -namespace ConduitLLM.Admin.Middleware; - -/// -/// Middleware for handling Admin API authentication -/// -public class AdminAuthenticationMiddleware -{ - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - private const string MASTER_KEY_CONFIG_KEY = "AdminApi:MasterKey"; - private const string MASTER_KEY_HEADER = "X-API-Key"; - - /// - /// Initializes a new instance of the AdminAuthenticationMiddleware class - /// - /// The next middleware in the pipeline - /// Logger - /// Application configuration - public AdminAuthenticationMiddleware( - RequestDelegate next, - ILogger logger, - IConfiguration configuration) - { - _next = next; - _logger = logger; - _configuration = configuration; - } - - /// - /// Processes the request - /// - /// The HTTP context - public async Task InvokeAsync(HttpContext context) - { - // Skip authentication for Swagger endpoints - if (context.Request.Path.StartsWithSegments("/swagger")) - { - await _next(context); - return; - } - - // Skip authentication for OPTIONS requests (CORS preflight) - if (context.Request.Method == "OPTIONS") - { - await _next(context); - return; - } - - // Skip authentication for health check endpoint - if (context.Request.Path.StartsWithSegments("/health")) - { - await _next(context); - return; - } - - // Check for CONDUIT_API_TO_API_BACKEND_AUTH_KEY first (new standard), then fall back to AdminApi:MasterKey - string? masterKey = Environment.GetEnvironmentVariable("CONDUIT_API_TO_API_BACKEND_AUTH_KEY") - ?? _configuration[MASTER_KEY_CONFIG_KEY]; - - if (string.IsNullOrEmpty(masterKey)) - { - _logger.LogWarning("Master key is not configured"); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new { error = "API key authentication is not configured" }); - return; - } - - if (!context.Request.Headers.TryGetValue(MASTER_KEY_HEADER, out var providedKey)) - { -_logger.LogWarning("No API key provided for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new { error = "API key is required" }); - return; - } - - if (providedKey != masterKey) - { -_logger.LogWarning("Invalid API key provided for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new { error = "Invalid API key" }); - return; - } - - await _next(context); - } -} diff --git a/Services/ConduitLLM.Admin/Middleware/AdminExceptionMiddleware.cs b/Services/ConduitLLM.Admin/Middleware/AdminExceptionMiddleware.cs new file mode 100644 index 000000000..8fc5ace2c --- /dev/null +++ b/Services/ConduitLLM.Admin/Middleware/AdminExceptionMiddleware.cs @@ -0,0 +1,55 @@ +using System.Text.Json; + +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Middleware; + +using Microsoft.AspNetCore.Hosting; + +namespace ConduitLLM.Admin.Middleware; + +/// +/// Global exception handling middleware for the Admin API. +/// Catches any unhandled exceptions that escape controller-level error handling +/// and returns standardized responses. +/// +/// +/// This is a safety net โ€” most exceptions are handled by . +/// This middleware catches anything that slips through, ensuring the Admin API never returns +/// raw exception details to clients. +/// +public class AdminExceptionMiddleware : ExceptionHandlingMiddlewareBase +{ + protected override string MiddlewareName => "AdminExceptionMiddleware"; + + public AdminExceptionMiddleware( + RequestDelegate next, + ILogger logger, + IWebHostEnvironment environment) + : base(next, logger, environment) + { + } + + /// + protected override string CreateErrorResponseJson( + string message, + ExceptionToResponseMapper.ExceptionMappingResult mapping) + { + var errorResponse = new ErrorResponseDto(message) { Code = mapping.ErrorCode }; + return JsonSerializer.Serialize(errorResponse, ErrorJsonOptions); + } +} + +/// +/// Extension methods for Admin exception middleware. +/// +public static class AdminExceptionMiddlewareExtensions +{ + /// + /// Adds the Admin API global exception handling middleware to the pipeline. + /// + public static IApplicationBuilder UseAdminExceptionHandling(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/Services/ConduitLLM.Admin/Middleware/AdminHttpMetricsMiddleware.cs b/Services/ConduitLLM.Admin/Middleware/AdminHttpMetricsMiddleware.cs index 1d9dbbd1c..f497fd09b 100644 --- a/Services/ConduitLLM.Admin/Middleware/AdminHttpMetricsMiddleware.cs +++ b/Services/ConduitLLM.Admin/Middleware/AdminHttpMetricsMiddleware.cs @@ -1,5 +1,5 @@ -using System.Diagnostics; using System.Text.RegularExpressions; +using ConduitLLM.Core.Middleware; using Prometheus; namespace ConduitLLM.Admin.Middleware @@ -8,11 +8,8 @@ namespace ConduitLLM.Admin.Middleware /// Middleware for collecting HTTP metrics for the Admin API. /// Tracks request/response metrics including duration, size, and status codes. /// - public class AdminHttpMetricsMiddleware + public class AdminHttpMetricsMiddleware : HttpMetricsMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - // Core HTTP metrics private static readonly Counter RequestsTotal = Prometheus.Metrics .CreateCounter("conduit_admin_http_requests_total", "Total number of HTTP requests to Admin API", @@ -60,107 +57,23 @@ public class AdminHttpMetricsMiddleware }); // Regex patterns for path normalization - private static readonly Regex GuidPattern = new Regex(@"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", RegexOptions.Compiled); - private static readonly Regex NumberPattern = new Regex(@"\b\d+\b", RegexOptions.Compiled); - - /// - /// Initializes a new instance of the class. - /// - /// The next middleware in the pipeline. - /// The logger instance. - public AdminHttpMetricsMiddleware(RequestDelegate next, ILogger logger) - { - _next = next; - _logger = logger; - } - - /// - /// Processes an individual request and records metrics. - /// - /// The HTTP context for the current request. - /// A task that represents the asynchronous operation. - public async Task InvokeAsync(HttpContext context) - { - var stopwatch = Stopwatch.StartNew(); - var path = NormalizePath(context.Request.Path.Value ?? "/"); - var method = context.Request.Method; - - // Track active requests - using (ActiveRequests.WithLabels(method, path).TrackInProgress()) - { - // Capture request size - if (context.Request.ContentLength.HasValue) - { - RequestSize.WithLabels(method, path).Observe(context.Request.ContentLength.Value); - } + private static readonly Regex GuidPattern = new(@"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", RegexOptions.Compiled); + private static readonly Regex NumberPattern = new(@"\b\d+\b", RegexOptions.Compiled); - // Store original response body stream - var originalBodyStream = context.Response.Body; - using var responseBody = new MemoryStream(); - context.Response.Body = responseBody; - - try - { - await _next(context); - - // Capture response size - var responseSize = responseBody.Length; - ResponseSize.WithLabels(method, path, context.Response.StatusCode.ToString()).Observe(responseSize); - - // Copy the response body back to the original stream - responseBody.Seek(0, SeekOrigin.Begin); - await responseBody.CopyToAsync(originalBodyStream); - } - catch (TaskCanceledException) - { - context.Response.StatusCode = 499; // Client closed request - ErrorsTotal.WithLabels(method, path, "499", "client_cancelled").Inc(); - throw; - } - catch (Exception ex) - { - var errorType = ex.GetType().Name; - ErrorsTotal.WithLabels(method, path, context.Response.StatusCode.ToString(), errorType).Inc(); - _logger.LogError(ex, "Unhandled exception in request pipeline"); - throw; - } - finally - { - context.Response.Body = originalBodyStream; - - // Record metrics - stopwatch.Stop(); - var statusCode = context.Response.StatusCode.ToString(); + public AdminHttpMetricsMiddleware(RequestDelegate next, ILogger logger) + : base(next, logger) { } - RequestsTotal.WithLabels(method, path, statusCode).Inc(); - RequestDuration.WithLabels(method, path, statusCode).Observe(stopwatch.Elapsed.TotalSeconds); + protected override bool ShouldSkipMetrics(HttpContext context) => false; - // Log slow requests - if (stopwatch.Elapsed.TotalSeconds > 5) - { - _logger.LogWarning("Slow request detected: {Method} {Path} took {Duration}s with status {StatusCode}", - method, path, stopwatch.Elapsed.TotalSeconds, statusCode); - } - } - } - } - - /// - /// Normalizes request paths to reduce cardinality in metrics. - /// Replaces GUIDs and numeric IDs with placeholders. - /// - private static string NormalizePath(string path) + protected override string GetNormalizedPath(HttpContext context) { + var path = context.Request.Path.Value ?? "/"; + if (string.IsNullOrEmpty(path)) return "/"; - // Normalize common Admin API endpoints path = path.ToLowerInvariant(); - - // Replace GUIDs with {id} path = GuidPattern.Replace(path, "{id}"); - - // Replace numeric IDs with {id} path = NumberPattern.Replace(path, "{id}"); // Specific normalization for Admin API endpoints @@ -184,5 +97,28 @@ private static string NormalizePath(string path) return path; } + + protected override void IncrementActiveRequests(string method, string path) + => ActiveRequests.WithLabels(method, path).Inc(); + + protected override void DecrementActiveRequests(string method, string path) + => ActiveRequests.WithLabels(method, path).Dec(); + + protected override void RecordRequestSize(string method, string path, long bytes) + => RequestSize.WithLabels(method, path).Observe(bytes); + + protected override void RecordError(string method, string path, int statusCode, string errorType) + => ErrorsTotal.WithLabels(method, path, statusCode.ToString(), errorType).Inc(); + + protected override void RecordResponseMetrics( + string method, string path, int statusCode, double durationSeconds, + long responseBytes, HttpContext context) + { + var statusCodeStr = statusCode.ToString(); + + ResponseSize.WithLabels(method, path, statusCodeStr).Observe(responseBytes); + RequestsTotal.WithLabels(method, path, statusCodeStr).Inc(); + RequestDuration.WithLabels(method, path, statusCodeStr).Observe(durationSeconds); + } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Middleware/AdminRequestTrackingMiddleware.cs b/Services/ConduitLLM.Admin/Middleware/AdminRequestTrackingMiddleware.cs index cc40cd25d..ada4606a3 100644 --- a/Services/ConduitLLM.Admin/Middleware/AdminRequestTrackingMiddleware.cs +++ b/Services/ConduitLLM.Admin/Middleware/AdminRequestTrackingMiddleware.cs @@ -1,63 +1,23 @@ using ConduitLLM.Core.Extensions; -using System.Diagnostics; +using ConduitLLM.Core.Middleware; namespace ConduitLLM.Admin.Middleware; /// -/// Middleware for tracking Admin API requests +/// Middleware for tracking Admin API requests with structured logging. /// -public class AdminRequestTrackingMiddleware +public class AdminRequestTrackingMiddleware : RequestTrackingMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the AdminRequestTrackingMiddleware class - /// - /// The next middleware in the pipeline - /// Logger public AdminRequestTrackingMiddleware( RequestDelegate next, ILogger logger) - { - _next = next; - _logger = logger; - } - - /// - /// Processes the request - /// - /// The HTTP context - public async Task InvokeAsync(HttpContext context) - { - var stopwatch = Stopwatch.StartNew(); - var requestPath = context.Request.Path; - var requestMethod = context.Request.Method; + : base(next, logger) { } - try - { -_logger.LogInformation("Admin API Request: {Method} {Path} started", LoggingSanitizer.S(requestMethod), LoggingSanitizer.S(requestPath.ToString())); + protected override string ServiceName => "Admin API"; - // Call the next middleware in the pipeline - await _next(context); - - stopwatch.Stop(); - - _logger.LogInformation( - "Admin API Request: {Method} {Path} completed with status {StatusCode} in {ElapsedMs}ms", - LoggingSanitizer.S(requestMethod), LoggingSanitizer.S(requestPath.ToString()), context.Response.StatusCode, stopwatch.ElapsedMilliseconds); - } - catch (Exception ex) - { - stopwatch.Stop(); - - _logger.LogError( - ex, - "Admin API Request: {Method} {Path} failed after {ElapsedMs}ms", - LoggingSanitizer.S(requestMethod), LoggingSanitizer.S(requestPath.ToString()), stopwatch.ElapsedMilliseconds); - - // Re-throw the exception to be handled by the exception handler middleware - throw; - } + protected override void OnBeforeRequest(HttpContext context, string method, string path) + { + Logger.LogDebug("Admin API Request: {Method} {Path} started", + LoggingSanitizer.S(method), path); } } diff --git a/Services/ConduitLLM.Admin/Middleware/EphemeralMasterKeyCleanupMiddleware.cs b/Services/ConduitLLM.Admin/Middleware/EphemeralMasterKeyCleanupMiddleware.cs index b5baede3d..119e17764 100644 --- a/Services/ConduitLLM.Admin/Middleware/EphemeralMasterKeyCleanupMiddleware.cs +++ b/Services/ConduitLLM.Admin/Middleware/EphemeralMasterKeyCleanupMiddleware.cs @@ -1,62 +1,28 @@ using ConduitLLM.Admin.Services; +using ConduitLLM.Core.Middleware; namespace ConduitLLM.Admin.Middleware { /// - /// Middleware to clean up ephemeral master keys after request completion + /// Middleware to clean up ephemeral master keys after request completion. /// - public class EphemeralMasterKeyCleanupMiddleware + public class EphemeralMasterKeyCleanupMiddleware : EphemeralKeyCleanupMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; + protected override string DeleteFlagKey => "DeleteEphemeralMasterKey"; + protected override string KeyStorageKey => "EphemeralMasterKey"; - /// - /// Initializes a new instance of the class. - /// - /// The next middleware in the pipeline - /// The logger + /// public EphemeralMasterKeyCleanupMiddleware( RequestDelegate next, ILogger logger) + : base(next, logger) { - _next = next; - _logger = logger; } /// - /// Processes the HTTP request and cleans up ephemeral master keys after completion + /// Processes the HTTP request and cleans up ephemeral master keys after completion. /// - /// The HTTP context - /// The ephemeral master key service - /// A task representing the asynchronous operation - public async Task InvokeAsync(HttpContext context, IEphemeralMasterKeyService ephemeralMasterKeyService) - { - try - { - await _next(context); - } - finally - { - // Clean up ephemeral master key if marked for deletion - if (context.Items.TryGetValue("DeleteEphemeralMasterKey", out var shouldDelete) && - shouldDelete is bool delete && delete) - { - if (context.Items.TryGetValue("EphemeralMasterKey", out var keyObj) && - keyObj is string ephemeralKey) - { - try - { - await ephemeralMasterKeyService.DeleteKeyAsync(ephemeralKey); - _logger.LogDebug("Cleaned up ephemeral master key after request completion"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to clean up ephemeral master key"); - // Don't throw - we don't want cleanup failures to affect the response - } - } - } - } - } + public Task InvokeAsync(HttpContext context, IEphemeralMasterKeyService ephemeralMasterKeyService) + => InvokeAsync(context, ephemeralMasterKeyService.DeleteKeyAsync); } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Middleware/SecurityHeadersMiddleware.cs b/Services/ConduitLLM.Admin/Middleware/SecurityHeadersMiddleware.cs deleted file mode 100644 index 890320a3a..000000000 --- a/Services/ConduitLLM.Admin/Middleware/SecurityHeadersMiddleware.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Microsoft.Extensions.Options; -using ConduitLLM.Admin.Options; - -namespace ConduitLLM.Admin.Middleware -{ - /// - /// Middleware that adds security headers to HTTP responses for the Admin API - /// - public class SecurityHeadersMiddleware - { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly SecurityHeadersOptions _options; - - /// - /// Initializes a new instance of the SecurityHeadersMiddleware - /// - public SecurityHeadersMiddleware( - RequestDelegate next, - ILogger logger, - IOptions securityOptions) - { - _next = next; - _logger = logger; - _options = securityOptions.Value.Headers; - } - - /// - /// Adds security headers to the HTTP response - /// - public async Task InvokeAsync(HttpContext context) - { - // Add security headers before processing the request - AddSecurityHeaders(context); - - await _next(context); - } - - private void AddSecurityHeaders(HttpContext context) - { - var headers = context.Response.Headers; - - // X-Content-Type-Options - Prevent MIME type sniffing - if (_options.XContentTypeOptions && !headers.ContainsKey("X-Content-Type-Options")) - { - headers.Append("X-Content-Type-Options", "nosniff"); - } - - // X-XSS-Protection - Enable XSS filtering (for older browsers) - if (_options.XXssProtection && !headers.ContainsKey("X-XSS-Protection")) - { - headers.Append("X-XSS-Protection", "1; mode=block"); - } - - // Strict-Transport-Security (HSTS) - Only for HTTPS - if (_options.Hsts.Enabled && context.Request.IsHttps && !headers.ContainsKey("Strict-Transport-Security")) - { - headers.Append("Strict-Transport-Security", $"max-age={_options.Hsts.MaxAge}; includeSubDomains"); - } - - // Add custom headers - foreach (var customHeader in _options.CustomHeaders) - { - if (!headers.ContainsKey(customHeader.Key)) - { - headers.Append(customHeader.Key, customHeader.Value); - } - } - - // Remove potentially dangerous headers - headers.Remove("X-Powered-By"); - headers.Remove("Server"); - - // Add API-specific headers - headers.Append("X-Content-Type", "application/json"); - headers.Append("X-API-Version", "v1"); - - _logger.LogDebug("Security headers added to response for {Path}", context.Request.Path); - } - } - - /// - /// Extension methods for adding security headers middleware - /// - public static class SecurityHeadersMiddlewareExtensions - { - /// - /// Adds security headers middleware to the application pipeline - /// - public static IApplicationBuilder UseAdminSecurityHeaders(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Middleware/SecurityMiddleware.cs b/Services/ConduitLLM.Admin/Middleware/SecurityMiddleware.cs index 89b2e0968..0664583a8 100644 --- a/Services/ConduitLLM.Admin/Middleware/SecurityMiddleware.cs +++ b/Services/ConduitLLM.Admin/Middleware/SecurityMiddleware.cs @@ -1,22 +1,22 @@ -using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Admin.Metrics; +using ConduitLLM.Security.Middleware; +using ConduitLLM.Security.Models; +using ISecurityService = ConduitLLM.Security.Interfaces.ISecurityService; namespace ConduitLLM.Admin.Middleware { /// - /// Unified security middleware for Admin API that handles authentication, rate limiting, and IP filtering + /// Unified security middleware for Admin API that handles authentication, rate limiting, and IP filtering. + /// Inherits from SecurityMiddlewareBase for common functionality. /// - public class SecurityMiddleware + public class SecurityMiddleware : SecurityMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - /// /// Initializes a new instance of the SecurityMiddleware /// public SecurityMiddleware(RequestDelegate next, ILogger logger) + : base(next, logger) { - _next = next; - _logger = logger; } /// @@ -24,34 +24,46 @@ public SecurityMiddleware(RequestDelegate next, ILogger logg /// public async Task InvokeAsync(HttpContext context, ISecurityService securityService) { - var result = await securityService.IsRequestAllowedAsync(context); - - if (!result.IsAllowed) - { - _logger.LogWarning("Request blocked: {Reason} for path {Path} from IP {IP}", - result.Reason, - context.Request.Path, - context.Connection.RemoteIpAddress); + await ProcessRequestAsync(context, ctx => securityService.IsRequestAllowedAsync(ctx)); + } - context.Response.StatusCode = result.StatusCode ?? 403; - - // Add appropriate headers for rate limiting - if (result.StatusCode == 429) - { - context.Response.Headers.Append("Retry-After", "60"); - context.Response.Headers.Append("X-RateLimit-Limit", "100"); // Will be made configurable - } + /// + /// Logs granular security events distinguishing auth failures, rate limits, and IP blocks. + /// + protected override Task OnSecurityViolationAsync(HttpContext context, SecurityCheckResult result, string clientIp) + { + var method = context.Request.Method; + var path = context.Request.Path.Value ?? ""; - // Return JSON error response - await context.Response.WriteAsJsonAsync(new - { - error = result.Reason, - statusCode = result.StatusCode - }); - return; + switch (result.StatusCode) + { + case 401: + Logger.LogWarning( + "Security event: AuthenticationFailure โ€” {Method} {Path} from {ClientIp}. Reason: {Reason}", + method, path, clientIp, result.Reason); + AdminSecurityMetrics.RecordAuthFailure(); + break; + case 429: + Logger.LogWarning( + "Security event: RateLimitExceeded โ€” {Method} {Path} from {ClientIp}. Reason: {Reason}", + method, path, clientIp, result.Reason); + AdminSecurityMetrics.RecordRateLimitHit(); + break; + case 403: + Logger.LogWarning( + "Security event: AccessDenied โ€” {Method} {Path} from {ClientIp}. Reason: {Reason}", + method, path, clientIp, result.Reason); + AdminSecurityMetrics.RecordAccessDenied(); + break; + default: + Logger.LogWarning( + "Security event: Blocked ({StatusCode}) โ€” {Method} {Path} from {ClientIp}. Reason: {Reason}", + result.StatusCode, method, path, clientIp, result.Reason); + AdminSecurityMetrics.RecordBlocked(); + break; } - await _next(context); + return Task.CompletedTask; } } @@ -68,4 +80,4 @@ public static IApplicationBuilder UseAdminSecurity(this IApplicationBuilder buil return builder.UseMiddleware(); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Models/EphemeralMasterKeyData.cs b/Services/ConduitLLM.Admin/Models/EphemeralMasterKeyData.cs index 1b4400739..0919407ed 100644 --- a/Services/ConduitLLM.Admin/Models/EphemeralMasterKeyData.cs +++ b/Services/ConduitLLM.Admin/Models/EphemeralMasterKeyData.cs @@ -1,30 +1,12 @@ +using ConduitLLM.Core.Models; + namespace ConduitLLM.Admin.Models { /// /// Represents data for an ephemeral master key stored in cache /// - public class EphemeralMasterKeyData + public class EphemeralMasterKeyData : EphemeralKeyDataBase { - /// - /// The ephemeral master key token - /// - public string Key { get; set; } = string.Empty; - - /// - /// When the key was created - /// - public DateTimeOffset CreatedAt { get; set; } - - /// - /// When the key expires - /// - public DateTimeOffset ExpiresAt { get; set; } - - /// - /// Whether the key has been consumed - /// - public bool IsConsumed { get; set; } - /// /// Flag indicating this is a valid master key token /// diff --git a/Services/ConduitLLM.Admin/Models/Models/ModelDto.cs b/Services/ConduitLLM.Admin/Models/Models/ModelDto.cs index d2190abce..d8506e316 100644 --- a/Services/ConduitLLM.Admin/Models/Models/ModelDto.cs +++ b/Services/ConduitLLM.Admin/Models/Models/ModelDto.cs @@ -1,7 +1,44 @@ +using System.Collections.Generic; using ConduitLLM.Admin.Models.ModelSeries; namespace ConduitLLM.Admin.Models.Models { + /// + /// Lightweight DTO for a model's provider type association (identifier). + /// + public class ModelIdentifierDto + { + /// Gets or sets the unique identifier for this model-provider association. + public int Id { get; set; } + + /// Gets or sets the provider-specific model identifier string (e.g., "gpt-4-turbo" for OpenAI). + public string Identifier { get; set; } = string.Empty; + + /// Gets or sets the provider ID that offers this model, or null if unassigned. + public int? Provider { get; set; } + + /// Gets or sets whether this is the primary (preferred) provider for the model. + public bool IsPrimary { get; set; } + + /// Gets or sets the maximum input token limit for this provider's offering, or null if unknown. + public int? MaxInputTokens { get; set; } + + /// Gets or sets the maximum output token limit for this provider's offering, or null if unknown. + public int? MaxOutputTokens { get; set; } + + /// Gets or sets the relative speed score for this provider's offering, used for routing decisions. + public decimal? SpeedScore { get; set; } + + /// Gets or sets the relative quality score for this provider's offering, used for routing decisions. + public decimal? QualityScore { get; set; } + + /// Gets or sets the provider-specific variation label (e.g., "turbo", "mini") if applicable. + public string? ProviderVariation { get; set; } + + /// Gets or sets the associated model cost configuration ID, or null if no cost tracking is configured. + public int? ModelCostId { get; set; } + } + /// /// Data transfer object representing a canonical AI model in the system. /// @@ -149,5 +186,15 @@ public class ModelDto /// /// JSON string containing parameter definitions, or null to use series defaults. public string? ModelParameters { get; set; } + + /// + /// Gets or sets the provider type associations (identifiers) for this model. + /// + /// + /// Included when the model is fetched with details. Each identifier represents + /// a provider-specific mapping showing which providers offer this model and under + /// what identifier string. + /// + public List? Identifiers { get; set; } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Program.Messaging.cs b/Services/ConduitLLM.Admin/Program.Messaging.cs new file mode 100644 index 000000000..cef2a938c --- /dev/null +++ b/Services/ConduitLLM.Admin/Program.Messaging.cs @@ -0,0 +1,77 @@ +using MassTransit; + +namespace ConduitLLM.Admin; + +public partial class Program +{ + /// + /// Configures MassTransit event bus with RabbitMQ or in-memory transport. + /// + private static void ConfigureMessagingServices(WebApplicationBuilder builder, ILogger startupLogger) + { + // Configure RabbitMQ settings + var rabbitMqConfig = builder.Configuration.GetSection("ConduitLLM:RabbitMQ").Get() + ?? new ConduitLLM.Configuration.RabbitMqConfiguration(); + + // Check if RabbitMQ is configured + var useRabbitMq = !string.IsNullOrEmpty(rabbitMqConfig.Host) && rabbitMqConfig.Host != "localhost"; + + // Register MassTransit event bus for Admin API + builder.Services.AddMassTransit(x => + { + // Register consumers for Admin API cache invalidation + x.AddConsumer(); + + // Add Function Discovery Cache invalidation consumers + x.AddConsumer(); + x.AddConsumer(); + + if (useRabbitMq) + { + x.UsingRabbitMq((context, cfg) => + { + // Configure RabbitMQ connection with advanced settings + cfg.Host(new Uri($"rabbitmq://{rabbitMqConfig.Host}:{rabbitMqConfig.Port}{rabbitMqConfig.VHost}"), h => + { + h.Username(rabbitMqConfig.Username); + h.Password(rabbitMqConfig.Password); + h.Heartbeat(TimeSpan.FromSeconds(rabbitMqConfig.RequestedHeartbeat)); + + // Publisher settings + h.PublisherConfirmation = rabbitMqConfig.PublisherConfirmation; + + // Advanced connection settings for publishers + h.RequestedChannelMax(rabbitMqConfig.ChannelMax); + }); + + // Configure retry policy for publishing and consuming + cfg.UseMessageRetry(r => r.Exponential(3, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(2))); + + // Configure endpoints including consumers + cfg.ConfigureEndpoints(context); + }); + + startupLogger.LogInformation( + "Event bus configured with RabbitMQ transport (multi-instance mode) โ€” Host: {Host}:{Port}. Publishing and consuming enabled", + rabbitMqConfig.Host, rabbitMqConfig.Port); + } + else + { + x.UsingInMemory((context, cfg) => + { + // Configure retry policy for reliability + cfg.UseMessageRetry(r => r.Incremental(3, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2))); + + // Configure delayed redelivery for failed messages + cfg.UseDelayedRedelivery(r => r.Intervals(TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(30))); + + // Configure endpoints + cfg.ConfigureEndpoints(context); + }); + + startupLogger.LogInformation("Event bus configured with in-memory transport (single-instance mode). Events will be processed locally"); + startupLogger.LogWarning("For production multi-instance deployments, configure RabbitMQ to ensure cross-instance cache invalidation"); + } + }); + } +} diff --git a/Services/ConduitLLM.Admin/Program.Monitoring.cs b/Services/ConduitLLM.Admin/Program.Monitoring.cs new file mode 100644 index 000000000..ddd4fa3a0 --- /dev/null +++ b/Services/ConduitLLM.Admin/Program.Monitoring.cs @@ -0,0 +1,113 @@ +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Utilities; + +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +using Prometheus; + +namespace ConduitLLM.Admin; + +public partial class Program +{ + /// + /// Configures health checks, OpenTelemetry metrics/tracing, and monitoring services. + /// + private static void ConfigureMonitoringServices(WebApplicationBuilder builder, ILogger startupLogger) + { + // Add basic health checks + builder.Services.AddHealthChecks(); + + // Add connection pool warmer with coordinated warming to prevent thundering herd during deployments + builder.Services.AddCoordinatedConnectionPoolWarming(builder.Configuration, "AdminAPI"); + + // Configure OpenTelemetry metrics and tracing + var otlpEndpoint = builder.Configuration["Telemetry:OtlpEndpoint"] ?? "http://localhost:4317"; + var tracingEnabled = builder.Configuration.GetValue("Telemetry:TracingEnabled", true); + + var otelBuilder = builder.Services.AddOpenTelemetry() + .WithMetrics(meterProviderBuilder => + { + meterProviderBuilder + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService(serviceName: "ConduitLLM.Admin", serviceVersion: "1.0.0")) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddMeter("System.Runtime") + .AddMeter("Microsoft.AspNetCore.Hosting") + .AddMeter("Microsoft.AspNetCore.Server.Kestrel") + .AddMeter("ConduitLLM.Admin.Requests") + .AddMeter("ConduitLLM.Providers") + .AddPrometheusExporter(); + }); + + // Add distributed tracing when enabled + if (tracingEnabled) + { + otelBuilder.WithTracing(tracerProviderBuilder => + { + tracerProviderBuilder + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService(serviceName: "ConduitLLM.Admin", serviceVersion: "1.0.0")) + .AddAspNetCoreInstrumentation(options => + { + // Filter out health check endpoints to reduce noise + options.Filter = httpContext => + !httpContext.Request.Path.StartsWithSegments("/health") && + !httpContext.Request.Path.StartsWithSegments("/metrics"); + }) + .AddHttpClientInstrumentation() + .AddSource("ConduitLLM.Admin.Requests") + .AddSource("ConduitLLM.Providers") + .AddOtlpExporter(options => + { + options.Endpoint = new Uri(otlpEndpoint); + }); + }); + startupLogger.LogInformation("OpenTelemetry tracing enabled โ€” exporting to {OtlpEndpoint}", otlpEndpoint); + } + else + { + startupLogger.LogInformation("OpenTelemetry tracing disabled (set Telemetry:TracingEnabled=true to enable)"); + } + + // Add monitoring services - with leader election + builder.Services.AddLeaderElectedHostedService("AdminOperationsMetricsService"); + } + + /// + /// Maps health check, metrics, and Prometheus endpoints. + /// + private static void MapMonitoringEndpoints(WebApplication app) + { + // Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = check => check.Tags.Contains("live") + }); + app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = check => check.Tags.Contains("ready") || check.Tags.Count == 0 + }); + + app.Logger.LogInformation("Health check endpoints registered: /health, /health/live, /health/ready"); + + // Map Prometheus metrics endpoint + app.UseOpenTelemetryPrometheusScrapingEndpoint( + context => context.Request.Path == "/metrics" && + (IpAddressHelper.IsPrivateNetworkRequest(context) || + context.User.Identity?.IsAuthenticated == true)); + + // For the prometheus-net library metrics + app.UseHttpMetrics(options => + { + options.ReduceStatusCodeCardinality(); + options.RequestDuration.Enabled = false; + options.RequestCount.Enabled = false; + }); + } +} diff --git a/Services/ConduitLLM.Admin/Program.Services.cs b/Services/ConduitLLM.Admin/Program.Services.cs new file mode 100644 index 000000000..a83fc21c7 --- /dev/null +++ b/Services/ConduitLLM.Admin/Program.Services.cs @@ -0,0 +1,65 @@ +using ConduitLLM.Admin.Extensions; +using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Configuration.Utilities; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Providers.Extensions; + +namespace ConduitLLM.Admin; + +public partial class Program +{ + /// + /// Configures core application services: DI registrations, Redis, SignalR, distributed cache. + /// + private static void ConfigureCoreServices(WebApplicationBuilder builder, ILogger startupLogger) + { + // Add leader election service for distributed background service coordination + builder.Services.AddLeaderElection(); + startupLogger.LogInformation("Leader election service configured for background service coordination"); + + // Add Core services + builder.Services.AddCoreServices(builder.Configuration, startupLogger); + + // Add Configuration services + builder.Services.AddConfigurationServices(builder.Configuration); + + // Add Provider services (needed for ILLMClientFactory) + builder.Services.AddProviderServices(); + + // Add Admin services + builder.Services.AddAdminServices(builder.Configuration); + + // Configure Data Protection with Redis persistence + var redisConnectionString = RedisUrlParser.ResolveConnectionString(); + builder.Services.AddRedisDataProtection(redisConnectionString, "Conduit"); + + // Add Redis as distributed cache for ephemeral key storage + if (!string.IsNullOrEmpty(redisConnectionString)) + { + builder.Services.AddStackExchangeRedisCache(options => + { + options.Configuration = redisConnectionString; + options.InstanceName = "conduit:"; + }); + startupLogger.LogInformation("Distributed cache configured with Redis"); + } + else + { + // Fallback to in-memory cache if Redis is not configured + builder.Services.AddDistributedMemoryCache(); + startupLogger.LogWarning("Using in-memory cache โ€” ephemeral keys will not work across instances"); + } + + // Add SignalR with shared configuration (MessagePack, Redis backplane) + var signalRRedisConnectionString = builder.Configuration.GetConnectionString("RedisSignalR") ?? redisConnectionString; + builder.Services.AddConduitSignalR( + builder.Environment, + signalRRedisConnectionString, + redisChannelPrefix: "conduit_admin_signalr:", + redisDatabase: 3, + serviceName: "ConduitLLM.Admin"); + + // Add media lifecycle services (scheduler, storage, distributed locking) + builder.Services.AddMediaLifecycleServices(builder.Configuration); + } +} diff --git a/Services/ConduitLLM.Admin/Program.cs b/Services/ConduitLLM.Admin/Program.cs index 03c92ac72..88587e20d 100644 --- a/Services/ConduitLLM.Admin/Program.cs +++ b/Services/ConduitLLM.Admin/Program.cs @@ -1,21 +1,9 @@ -using System.Reflection; - using ConduitLLM.Admin.Extensions; using ConduitLLM.Configuration.Data; using ConduitLLM.Configuration.Extensions; using ConduitLLM.Core.Converters; -using ConduitLLM.Core.Extensions; -using ConduitLLM.Core.Utilities; -using ConduitLLM.Providers.Extensions; - -using MassTransit; // Added for event bus infrastructure - +using ConduitLLM.Security.Middleware; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; - -using Prometheus; using Scalar.AspNetCore; namespace ConduitLLM.Admin; @@ -33,6 +21,10 @@ public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + // Create a startup logger for structured logging during service registration + using var startupLoggerFactory = LoggerFactory.Create(b => b.AddConsole()); + var startupLogger = startupLoggerFactory.CreateLogger("ConduitLLM.Admin.Startup"); + // Add services to the container builder.Services.AddControllers() .AddJsonOptions(options => @@ -42,11 +34,9 @@ public static async Task Main(string[] args) options.JsonSerializerOptions.DictionaryKeyPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; // IMPORTANT: Make JSON deserialization case-insensitive to prevent bugs - // This allows the API to accept both "initialBalance" and "InitialBalance" options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; // Ensure all DateTime values serialize as UTC with 'Z' suffix - // Fixes issue where EF Core loses DateTimeKind metadata from PostgreSQL options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter()); options.JsonSerializerOptions.Converters.Add(new NullableUtcDateTimeConverter()); }); @@ -62,246 +52,10 @@ public static async Task Main(string[] args) options.AddOperationTransformer(); }); - // Add leader election service for distributed background service coordination - builder.Services.AddLeaderElection(); - Console.WriteLine("[ConduitLLM.Admin] Leader election service configured for background service coordination"); - - // Add Core services - builder.Services.AddCoreServices(builder.Configuration); - - // Add Configuration services - builder.Services.AddConfigurationServices(builder.Configuration); - - // Add Provider services (needed for ILLMClientFactory) - builder.Services.AddProviderServices(); - - // Add Admin services - builder.Services.AddAdminServices(builder.Configuration); - - // Configure Data Protection with Redis persistence - // Check for REDIS_URL first, then fall back to CONDUIT_REDIS_CONNECTION_STRING - var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); - var redisConnectionString = Environment.GetEnvironmentVariable("CONDUIT_REDIS_CONNECTION_STRING"); - - if (!string.IsNullOrEmpty(redisUrl)) - { - try - { - redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ParseRedisUrl(redisUrl); - } - catch - { - // Failed to parse REDIS_URL, will use legacy connection string if available - } - } - - builder.Services.AddRedisDataProtection(redisConnectionString, "Conduit"); - - // Add Redis as distributed cache for ephemeral key storage - if (!string.IsNullOrEmpty(redisConnectionString)) - { - builder.Services.AddStackExchangeRedisCache(options => - { - options.Configuration = redisConnectionString; - options.InstanceName = "conduit:"; - }); - Console.WriteLine("[ConduitLLM.Admin] Distributed cache configured with Redis"); - } - else - { - // Fallback to in-memory cache if Redis is not configured - builder.Services.AddDistributedMemoryCache(); - Console.WriteLine("[ConduitLLM.Admin] WARNING: Using in-memory cache - ephemeral keys will not work across instances"); - } - - // Add SignalR with configuration - var signalRBuilder = builder.Services.AddSignalR(options => - { - options.EnableDetailedErrors = builder.Environment.IsDevelopment(); - options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); - options.KeepAliveInterval = TimeSpan.FromSeconds(30); - options.MaximumReceiveMessageSize = 32 * 1024; // 32KB - options.StreamBufferCapacity = 10; - }); - - // Add MessagePack protocol support with LZ4 compression - // Enables both JSON (default) and MessagePack protocols for backward compatibility - var messagePackEnabled = Environment.GetEnvironmentVariable("SIGNALR_MESSAGEPACK_ENABLED")?.ToLowerInvariant() != "false"; - if (messagePackEnabled) - { - signalRBuilder.AddMessagePackProtocol(options => - { - // Configure MessagePack with security and compression - options.SerializerOptions = MessagePack.MessagePackSerializerOptions.Standard - .WithResolver(MessagePack.Resolvers.StandardResolver.Instance) - .WithSecurity(MessagePack.MessagePackSecurity.UntrustedData) // CVE-2020-5234 protection - .WithCompression(MessagePack.MessagePackCompression.Lz4BlockArray) // Use Lz4BlockArray for GC optimization - .WithCompressionMinLength(256); // Only compress messages > 256 bytes - }); - Console.WriteLine("[ConduitLLM.Admin] SignalR configured with MessagePack protocol (LZ4 compression enabled)"); - Console.WriteLine("[ConduitLLM.Admin] SignalR supports both JSON and MessagePack protocols for backward compatibility"); - } - else - { - Console.WriteLine("[ConduitLLM.Admin] SignalR configured with JSON protocol only (MessagePack disabled)"); - } - - // Configure SignalR Redis backplane for horizontal scaling if Redis is configured - var signalRRedisConnectionString = builder.Configuration.GetConnectionString("RedisSignalR") ?? redisConnectionString; - if (!string.IsNullOrEmpty(signalRRedisConnectionString)) - { - signalRBuilder.AddStackExchangeRedis(signalRRedisConnectionString, options => - { - options.Configuration.ChannelPrefix = new StackExchange.Redis.RedisChannel("conduit_admin_signalr:", StackExchange.Redis.RedisChannel.PatternMode.Literal); - options.Configuration.DefaultDatabase = 3; // Separate database for Admin SignalR - }); - Console.WriteLine("[ConduitLLM.Admin] SignalR configured with Redis backplane for horizontal scaling"); - } - else - { - Console.WriteLine("[ConduitLLM.Admin] SignalR configured without Redis backplane (single-instance mode)"); - } - - // Configure RabbitMQ settings - var rabbitMqConfig = builder.Configuration.GetSection("ConduitLLM:RabbitMQ").Get() - ?? new ConduitLLM.Configuration.RabbitMqConfiguration(); - - // Check if RabbitMQ is configured - var useRabbitMq = !string.IsNullOrEmpty(rabbitMqConfig.Host) && rabbitMqConfig.Host != "localhost"; - - // Add media lifecycle services (scheduler, storage, distributed locking) - builder.Services.AddMediaLifecycleServices(builder.Configuration); - - // Register MassTransit event bus for Admin API - builder.Services.AddMassTransit(x => - { - // Register consumers for Admin API cache invalidation - x.AddConsumer(); - - // Add Function Discovery Cache invalidation consumers - x.AddConsumer(); - x.AddConsumer(); - - // Register consumers for Admin API SignalR notifications - // Provider health consumer removed - - if (useRabbitMq) - { - x.UsingRabbitMq((context, cfg) => - { - // Configure RabbitMQ connection with advanced settings - cfg.Host(new Uri($"rabbitmq://{rabbitMqConfig.Host}:{rabbitMqConfig.Port}{rabbitMqConfig.VHost}"), h => - { - h.Username(rabbitMqConfig.Username); - h.Password(rabbitMqConfig.Password); - h.Heartbeat(TimeSpan.FromSeconds(rabbitMqConfig.RequestedHeartbeat)); - - // Publisher settings - h.PublisherConfirmation = rabbitMqConfig.PublisherConfirmation; - - // Advanced connection settings for publishers - h.RequestedChannelMax(rabbitMqConfig.ChannelMax); - }); - - // Configure retry policy for publishing and consuming - cfg.UseMessageRetry(r => r.Exponential(3, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(2))); - - // Configure endpoints including consumers - cfg.ConfigureEndpoints(context); - }); - - Console.WriteLine($"[ConduitLLM.Admin] Event bus configured with RabbitMQ transport (multi-instance mode) - Host: {rabbitMqConfig.Host}:{rabbitMqConfig.Port}"); - Console.WriteLine("[ConduitLLM.Admin] Event publishing ENABLED - Admin services will publish:"); - Console.WriteLine(" - VirtualKeyUpdated events (triggers cache invalidation in Gateway API)"); - Console.WriteLine(" - VirtualKeyDeleted events (triggers cache cleanup in Gateway API)"); - Console.WriteLine(" - ProviderUpdated events (triggers capability refresh)"); - Console.WriteLine(" - ProviderDeleted events (triggers cache cleanup)"); - Console.WriteLine(" - GlobalSettingChanged events (triggers cache invalidation in all instances)"); - Console.WriteLine("[ConduitLLM.Admin] Event consuming ENABLED - Admin services will consume:"); - Console.WriteLine(" - GlobalSettingChanged events (keeps Admin API cache synchronized)"); - } - else - { - x.UsingInMemory((context, cfg) => - { - // NOTE: Using in-memory transport for single-instance deployments - // Configure RabbitMQ environment variables for multi-instance production - - // Configure retry policy for reliability - cfg.UseMessageRetry(r => r.Incremental(3, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2))); - - // Configure delayed redelivery for failed messages - cfg.UseDelayedRedelivery(r => r.Intervals(TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(30))); - - // Configure endpoints - cfg.ConfigureEndpoints(context); - }); - - Console.WriteLine("[ConduitLLM.Admin] Event bus configured with in-memory transport (single-instance mode)"); - Console.WriteLine("[ConduitLLM.Admin] Event publishing and consuming ENABLED - Events will be processed locally"); - Console.WriteLine("[ConduitLLM.Admin] WARNING: For production multi-instance deployments, configure RabbitMQ"); - Console.WriteLine(" - This ensures Gateway API instances receive cache invalidation events"); - Console.WriteLine(" - Without RabbitMQ, only the local Gateway API instance will be notified"); - Console.WriteLine("[ConduitLLM.Admin] Event consuming ENABLED - Admin services will consume:"); - Console.WriteLine(" - GlobalSettingChanged events (keeps Admin API cache synchronized)"); - } - }); - - // Add basic health checks - builder.Services.AddHealthChecks(); - - // Add connection pool warmer with coordinated warming to prevent thundering herd during deployments - // Unlike leader election, ALL instances warm their pools, but in a staggered manner - builder.Services.AddCoordinatedConnectionPoolWarming(builder.Configuration, "AdminAPI"); - - // Configure OpenTelemetry metrics and tracing - var otlpEndpoint = builder.Configuration["Telemetry:OtlpEndpoint"] ?? "http://localhost:4317"; - var tracingEnabled = builder.Configuration.GetValue("Telemetry:TracingEnabled", true); - - var otelBuilder = builder.Services.AddOpenTelemetry() - .WithMetrics(meterProviderBuilder => - { - meterProviderBuilder - .SetResourceBuilder(ResourceBuilder.CreateDefault() - .AddService(serviceName: "ConduitLLM.Admin", serviceVersion: "1.0.0")) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddMeter("System.Runtime") - .AddMeter("Microsoft.AspNetCore.Hosting") - .AddMeter("Microsoft.AspNetCore.Server.Kestrel") - .AddPrometheusExporter(); - }); - - // Add distributed tracing when enabled - if (tracingEnabled) - { - otelBuilder.WithTracing(tracerProviderBuilder => - { - tracerProviderBuilder - .SetResourceBuilder(ResourceBuilder.CreateDefault() - .AddService(serviceName: "ConduitLLM.Admin", serviceVersion: "1.0.0")) - .AddAspNetCoreInstrumentation(options => - { - // Filter out health check endpoints to reduce noise - options.Filter = httpContext => - !httpContext.Request.Path.StartsWithSegments("/health") && - !httpContext.Request.Path.StartsWithSegments("/metrics"); - }) - .AddHttpClientInstrumentation() - .AddOtlpExporter(options => - { - options.Endpoint = new Uri(otlpEndpoint); - }); - }); - Console.WriteLine($"[ConduitLLM.Admin] OpenTelemetry tracing enabled - exporting to {otlpEndpoint}"); - } - else - { - Console.WriteLine("[ConduitLLM.Admin] OpenTelemetry tracing disabled (set Telemetry:TracingEnabled=true to enable)"); - } - - // Add monitoring services - with leader election - builder.Services.AddLeaderElectedHostedService("AdminOperationsMetricsService"); + // Configure services (partial class methods) + ConfigureCoreServices(builder, startupLogger); + ConfigureMessagingServices(builder, startupLogger); + ConfigureMonitoringServices(builder, startupLogger); var app = builder.Build(); @@ -310,7 +64,7 @@ public static async Task Main(string[] args) { var logger = scope.ServiceProvider.GetRequiredService>(); ConduitLLM.Configuration.Extensions.DeprecationWarnings.LogEnvironmentVariableDeprecations(logger); - + // Validate Redis URL if provided var envRedisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); if (!string.IsNullOrEmpty(envRedisUrl)) @@ -321,20 +75,18 @@ public static async Task Main(string[] args) // Run database migrations await app.RunDatabaseMigrationAsync(); + app.Logger.LogInformation("Database migrations completed successfully"); // Seed default data (e.g., default retention policy) await app.SeedDefaultDataAsync(); + app.Logger.LogInformation("Default data seeding completed"); // Configure the HTTP request pipeline if (app.Environment.IsDevelopment()) { - // Map the OpenAPI endpoint app.MapOpenApi("/openapi/v1.json"); - - // Map Scalar UI for interactive API documentation app.MapScalarApiReference(); - - Console.WriteLine("[ConduitLLM.Admin] Scalar UI available at /scalar/v1"); + app.Logger.LogInformation("Scalar UI available at /scalar/v1"); } // Only use HTTPS redirection if explicitly enabled @@ -344,46 +96,27 @@ public static async Task Main(string[] args) app.UseHttpsRedirection(); } + // Add health endpoint authorization (early in pipeline, before authentication) + app.UseHealthEndpointAuthorization(); + // Add middleware for authentication and request tracking app.UseAdminMiddleware(); - // Enable CORS for SignalR - app.UseCors("AdminCorsPolicy"); - app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); - - // Map SignalR hub with master key authentication (filter applied globally in AddSignalR) - app.MapHub("/hubs/admin-notifications"); - // Map health check endpoints - app.MapHealthChecks("/health"); - app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions - { - Predicate = check => check.Tags.Contains("live") - }); - app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions - { - Predicate = check => check.Tags.Contains("ready") || check.Tags.Count == 0 - }); + // Map SignalR hub with master key authentication + app.MapHub("/hubs/admin-notifications"); - // Map Prometheus metrics endpoint - // Allow unauthenticated access from private networks (Docker internal, localhost) - // Require authentication for external/public network requests - app.UseOpenTelemetryPrometheusScrapingEndpoint( - context => context.Request.Path == "/metrics" && - (IpAddressHelper.IsPrivateNetworkRequest(context) || - context.User.Identity?.IsAuthenticated == true)); + // Map monitoring endpoints (health, metrics, Prometheus) + MapMonitoringEndpoints(app); - // For the prometheus-net library metrics - app.UseHttpMetrics(options => - { - options.ReduceStatusCodeCardinality(); - options.RequestDuration.Enabled = false; // We're using our custom middleware - options.RequestCount.Enabled = false; // We're using our custom middleware - }); + app.Logger.LogInformation( + "Admin API started โ€” Environment: {Environment}, URLs: {Urls}", + app.Environment.EnvironmentName, + string.Join(", ", app.Urls)); app.Run(); } diff --git a/Services/ConduitLLM.Admin/Security/MasterKeyAuthenticationHandler.cs b/Services/ConduitLLM.Admin/Security/MasterKeyAuthenticationHandler.cs index 6fb75046b..8a9462e7a 100644 --- a/Services/ConduitLLM.Admin/Security/MasterKeyAuthenticationHandler.cs +++ b/Services/ConduitLLM.Admin/Security/MasterKeyAuthenticationHandler.cs @@ -1,8 +1,10 @@ using ConduitLLM.Core.Extensions; +using System.Diagnostics; using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; +using ConduitLLM.Admin.Metrics; using ConduitLLM.Admin.Services; using ConduitLLM.Core.Utilities; @@ -44,6 +46,8 @@ public MasterKeyAuthenticationHandler( /// The result of the authentication attempt protected override async Task HandleAuthenticateAsync() { + var sw = Stopwatch.StartNew(); + // Allow health check endpoints without authentication if (Context.Request.Path.StartsWithSegments("/health/live") || Context.Request.Path.StartsWithSegments("/health/ready") || @@ -59,6 +63,7 @@ protected override async Task HandleAuthenticateAsync() var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); + AdminAuthMetrics.RecordSuccess("HealthCheck"); return AuthenticateResult.Success(ticket); } @@ -99,6 +104,11 @@ protected override async Task HandleAuthenticateAsync() if (string.IsNullOrEmpty(providedKey)) { + Logger.LogWarning("Authentication failed: no API key provided for {Path}", + LoggingSanitizer.S(Context.Request.Path.ToString())); + sw.Stop(); + AdminAuthMetrics.RecordFailure("MasterKey", "missing_key"); + AdminAuthMetrics.RecordDuration("MasterKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Fail("Missing master key"); } @@ -120,10 +130,16 @@ protected override async Task HandleAuthenticateAsync() if (!keyExists) { Logger.LogWarning("Ephemeral master key not found: {Key}", SanitizeKeyForLogging(providedKey)); + sw.Stop(); + AdminAuthMetrics.RecordFailure("EphemeralKey", "not_found"); + AdminAuthMetrics.RecordDuration("EphemeralKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Fail("Ephemeral master key not found"); } - + Logger.LogWarning("Ephemeral master key already used or expired: {Key}", SanitizeKeyForLogging(providedKey)); + sw.Stop(); + AdminAuthMetrics.RecordFailure("EphemeralKey", "already_used"); + AdminAuthMetrics.RecordDuration("EphemeralKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Fail("Ephemeral master key already used"); } } @@ -138,10 +154,16 @@ protected override async Task HandleAuthenticateAsync() if (!keyExists) { Logger.LogWarning("Ephemeral master key not found: {Key}", SanitizeKeyForLogging(providedKey)); + sw.Stop(); + AdminAuthMetrics.RecordFailure("EphemeralKey", "not_found"); + AdminAuthMetrics.RecordDuration("EphemeralKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Fail("Ephemeral master key not found"); } - + Logger.LogWarning("Ephemeral master key validation failed: {Key}", SanitizeKeyForLogging(providedKey)); + sw.Stop(); + AdminAuthMetrics.RecordFailure("EphemeralKey", "expired"); + AdminAuthMetrics.RecordDuration("EphemeralKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Fail("Ephemeral master key expired"); } @@ -164,6 +186,9 @@ protected override async Task HandleAuthenticateAsync() var emkPrincipal = new ClaimsPrincipal(emkIdentity); var emkTicket = new AuthenticationTicket(emkPrincipal, Scheme.Name); + sw.Stop(); + AdminAuthMetrics.RecordSuccess("EphemeralKey"); + AdminAuthMetrics.RecordDuration("EphemeralKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Success(emkTicket); } @@ -171,11 +196,20 @@ protected override async Task HandleAuthenticateAsync() if (string.IsNullOrEmpty(_masterKey)) { Logger.LogError("Backend auth key is not configured. Set CONDUIT_API_TO_API_BACKEND_AUTH_KEY environment variable."); + sw.Stop(); + AdminAuthMetrics.RecordFailure("MasterKey", "not_configured"); + AdminAuthMetrics.RecordDuration("MasterKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Fail("Master key not configured"); } if (providedKey != _masterKey) { + Logger.LogWarning("Authentication failed: invalid master key provided for {Path} from {ClientIp}", + LoggingSanitizer.S(Context.Request.Path.ToString()), + Context.Connection.RemoteIpAddress?.ToString() ?? "unknown"); + sw.Stop(); + AdminAuthMetrics.RecordFailure("MasterKey", "invalid_key"); + AdminAuthMetrics.RecordDuration("MasterKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Fail("Invalid master key"); } @@ -191,6 +225,9 @@ protected override async Task HandleAuthenticateAsync() var authPrincipal = new ClaimsPrincipal(authIdentity); var authTicket = new AuthenticationTicket(authPrincipal, Scheme.Name); + sw.Stop(); + AdminAuthMetrics.RecordSuccess("MasterKey"); + AdminAuthMetrics.RecordDuration("MasterKey", sw.Elapsed.TotalSeconds); return AuthenticateResult.Success(authTicket); } diff --git a/Services/ConduitLLM.Admin/Services/AdminGlobalSettingService.cs b/Services/ConduitLLM.Admin/Services/AdminGlobalSettingService.cs index 2efb05cc4..c21263ed1 100644 --- a/Services/ConduitLLM.Admin/Services/AdminGlobalSettingService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminGlobalSettingService.cs @@ -26,8 +26,8 @@ public class AdminGlobalSettingService : EventPublishingServiceBase, IAdminGloba /// The logger public AdminGlobalSettingService( IGlobalSettingRepository globalSettingRepository, - IPublishEndpoint? publishEndpoint, - ILogger logger) + ILogger logger, + IPublishEndpoint? publishEndpoint = null) : base(publishEndpoint, logger) { _globalSettingRepository = globalSettingRepository ?? throw new ArgumentNullException(nameof(globalSettingRepository)); @@ -42,9 +42,9 @@ public async Task> GetAllSettingsAsync() { try { - _logger.LogInformation("Getting all global settings"); + _logger.LogDebug("Getting all global settings"); - var settings = await _globalSettingRepository.GetAllAsync(); + var settings = await _globalSettingRepository.GetAllUnboundedAsync(); return settings.Select(s => s.ToDto()).ToList(); } catch (Exception ex) @@ -59,7 +59,7 @@ public async Task> GetAllSettingsAsync() { try { - _logger.LogInformation("Getting global setting with ID: {Id}", id); + _logger.LogDebug("Getting global setting with ID: {Id}", id); var setting = await _globalSettingRepository.GetByIdAsync(id); return setting?.ToDto(); @@ -76,7 +76,7 @@ public async Task> GetAllSettingsAsync() { try { - _logger.LogInformation("Getting global setting with key: {Key}", LoggingSanitizer.S(key)); + _logger.LogDebug("Getting global setting with key: {Key}", LoggingSanitizer.S(key)); var setting = await _globalSettingRepository.GetByKeyAsync(key); return setting?.ToDto(); @@ -93,7 +93,7 @@ public async Task CreateSettingAsync(CreateGlobalSettingDto se { try { - _logger.LogInformation("Creating new global setting with key: {Key}", LoggingSanitizer.S(setting.Key)); + _logger.LogDebug("Creating new global setting with key: {Key}", LoggingSanitizer.S(setting.Key)); // Check if a setting with the same key already exists var existingSetting = await _globalSettingRepository.GetByKeyAsync(setting.Key); @@ -142,7 +142,7 @@ public async Task UpdateSettingAsync(UpdateGlobalSettingDto setting) { try { - _logger.LogInformation("Updating global setting with ID: {Id}", setting.Id); + _logger.LogDebug("Updating global setting with ID: {Id}", setting.Id); // Get the existing setting var existingSetting = await _globalSettingRepository.GetByIdAsync(setting.Id); @@ -168,7 +168,7 @@ public async Task UpdateSettingAsync(UpdateGlobalSettingDto setting) } // Only proceed if there are actual changes - if (changedProperties.Count() == 0) + if (!changedProperties.Any()) { _logger.LogDebug("No changes detected for global setting {Id} - skipping update", setting.Id); return true; @@ -210,7 +210,7 @@ public async Task UpdateSettingByKeyAsync(UpdateGlobalSettingByKeyDto sett { try { - _logger.LogInformation("Updating global setting with key: {Key}", LoggingSanitizer.S(setting.Key)); + _logger.LogDebug("Updating global setting with key: {Key}", LoggingSanitizer.S(setting.Key)); // Get existing setting to determine if this is an update or create var existingSetting = await _globalSettingRepository.GetByKeyAsync(setting.Key); @@ -254,7 +254,7 @@ public async Task DeleteSettingAsync(int id) { try { - _logger.LogInformation("Deleting global setting with ID: {Id}", id); + _logger.LogDebug("Deleting global setting with ID: {Id}", id); // Get the setting before deleting for event publishing var setting = await _globalSettingRepository.GetByIdAsync(id); @@ -296,7 +296,7 @@ public async Task DeleteSettingByKeyAsync(string key) { try { - _logger.LogInformation("Deleting global setting with key: {Key}", LoggingSanitizer.S(key)); + _logger.LogDebug("Deleting global setting with key: {Key}", LoggingSanitizer.S(key)); // Get the setting before deleting for event publishing var setting = await _globalSettingRepository.GetByKeyAsync(key); diff --git a/Services/ConduitLLM.Admin/Services/AdminIpFilterService.cs b/Services/ConduitLLM.Admin/Services/AdminIpFilterService.cs index 83fa856f8..834f2e25d 100644 --- a/Services/ConduitLLM.Admin/Services/AdminIpFilterService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminIpFilterService.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Admin.Extensions; using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Utilities; using ConduitLLM.Admin.Interfaces; @@ -42,8 +43,8 @@ public AdminIpFilterService( IIpFilterRepository ipFilterRepository, IGlobalSettingRepository globalSettingRepository, IOptionsMonitor ipFilterOptions, - IPublishEndpoint? publishEndpoint, - ILogger logger) + ILogger logger, + IPublishEndpoint? publishEndpoint = null) : base(publishEndpoint, logger) { _ipFilterRepository = ipFilterRepository ?? throw new ArgumentNullException(nameof(ipFilterRepository)); @@ -59,10 +60,10 @@ public async Task> GetAllFiltersAsync() { try { - _logger.LogInformation("Getting all IP filters"); + _logger.LogDebug("Getting all IP filters"); - var filters = await _ipFilterRepository.GetAllAsync(); - return filters.Select(MapToDto); + var filters = await _ipFilterRepository.GetAllUnboundedAsync(); + return filters.Select(f => f.ToDto()); } catch (Exception ex) { @@ -76,10 +77,10 @@ public async Task> GetEnabledFiltersAsync() { try { - _logger.LogInformation("Getting enabled IP filters"); + _logger.LogDebug("Getting enabled IP filters"); var filters = await _ipFilterRepository.GetEnabledAsync(); - return filters.Select(MapToDto); + return filters.Select(f => f.ToDto()); } catch (Exception ex) { @@ -93,10 +94,10 @@ public async Task> GetEnabledFiltersAsync() { try { - _logger.LogInformation("Getting IP filter with ID: {FilterId}", id); + _logger.LogDebug("Getting IP filter with ID: {FilterId}", id); var filter = await _ipFilterRepository.GetByIdAsync(id); - return filter != null ? MapToDto(filter) : null; + return filter?.ToDto(); } catch (Exception ex) { @@ -110,7 +111,7 @@ public async Task> GetEnabledFiltersAsync() { try { - _logger.LogInformation("Creating new IP filter for {IpAddress}", (LoggingSanitizer.S(createFilter.IpAddressOrCidr ?? ""))); + _logger.LogDebug("Creating new IP filter for {IpAddress}", (LoggingSanitizer.S(createFilter.IpAddressOrCidr ?? ""))); // Validate the IP address format if (string.IsNullOrWhiteSpace(createFilter.IpAddressOrCidr) || !IsValidIpAddressOrCidr(createFilter.IpAddressOrCidr)) @@ -148,8 +149,11 @@ await PublishEventAsync( $"create IP filter {createdFilter.Id}", new { IpAddressOrCidr = createdFilter.IpAddressOrCidr, FilterType = createdFilter.FilterType }); + _logger.LogInformation("IP filter created: {FilterId} type={FilterType} target={IpAddress}", + createdFilter.Id, createdFilter.FilterType, LoggingSanitizer.S(createdFilter.IpAddressOrCidr)); + // Return the created filter - return (true, null, MapToDto(createdFilter)); + return (true, null, createdFilter.ToDto()); } catch (Exception ex) { @@ -163,7 +167,7 @@ await PublishEventAsync( { try { - _logger.LogInformation("Updating IP filter with ID: {FilterId}", updateFilter.Id); + _logger.LogDebug("Updating IP filter with ID: {FilterId}", updateFilter.Id); // Check if the filter exists var existingFilter = await _ipFilterRepository.GetByIdAsync(updateFilter.Id); @@ -206,7 +210,7 @@ await PublishEventAsync( } // Only proceed if there are actual changes - if (changedProperties.Count() == 0) + if (!changedProperties.Any()) { _logger.LogDebug("No changes detected for IP filter {FilterId} - skipping update", updateFilter.Id); return (true, null); @@ -219,6 +223,9 @@ await PublishEventAsync( if (success) { + _logger.LogInformation("IP filter updated: {FilterId} changed=[{ChangedProperties}]", + existingFilter.Id, string.Join(", ", changedProperties)); + // Publish IpFilterChanged event for cache invalidation and cross-service coordination await PublishEventAsync( new IpFilterChanged @@ -239,6 +246,7 @@ await PublishEventAsync( } else { + _logger.LogWarning("Failed to update IP filter {FilterId} in database", updateFilter.Id); return (false, "Failed to update the IP filter"); } } @@ -254,7 +262,7 @@ await PublishEventAsync( { try { - _logger.LogInformation("Deleting IP filter with ID: {FilterId}", id); + _logger.LogDebug("Deleting IP filter with ID: {FilterId}", id); // Check if the filter exists var existingFilter = await _ipFilterRepository.GetByIdAsync(id); @@ -268,6 +276,9 @@ await PublishEventAsync( if (success) { + _logger.LogInformation("IP filter deleted: {FilterId} type={FilterType} target={IpAddress}", + existingFilter.Id, existingFilter.FilterType, LoggingSanitizer.S(existingFilter.IpAddressOrCidr)); + // Publish IpFilterChanged event for cache invalidation and cross-service coordination await PublishEventAsync( new IpFilterChanged @@ -288,6 +299,7 @@ await PublishEventAsync( } else { + _logger.LogWarning("Failed to delete IP filter {FilterId} from database", id); return (false, "Failed to delete the IP filter"); } } @@ -303,7 +315,7 @@ public async Task GetIpFilterSettingsAsync() { try { - _logger.LogInformation("Getting IP filter settings"); + _logger.LogDebug("Getting IP filter settings"); // Try to get settings from database first var enabledSetting = await _globalSettingRepository.GetByKeyAsync(SettingKeyEnabled); @@ -360,8 +372,9 @@ private List DeserializeExcludedEndpoints(string json) var endpoints = System.Text.Json.JsonSerializer.Deserialize>(json); return endpoints ?? new List { "/api/v1/health" }; } - catch + catch (Exception ex) { + _logger.LogWarning(ex, "Failed to deserialize excluded endpoints JSON, using defaults"); return new List { "/api/v1/health" }; } } @@ -371,7 +384,7 @@ private List DeserializeExcludedEndpoints(string json) { try { - _logger.LogInformation("Updating IP filter settings: Enabled={Enabled}, DefaultAllow={DefaultAllow}", + _logger.LogDebug("Updating IP filter settings: Enabled={Enabled}, DefaultAllow={DefaultAllow}", settings.IsEnabled, settings.DefaultAllow); // Validate settings @@ -433,7 +446,7 @@ public async Task CheckIpAddressAsync(string ipAddress) { try { - _logger.LogInformation("Checking if IP address is allowed: {IpAddress}", LoggingSanitizer.S(ipAddress)); + _logger.LogDebug("Checking if IP address is allowed: {IpAddress}", LoggingSanitizer.S(ipAddress)); // Get current IP filter settings var settings = await GetIpFilterSettingsAsync(); @@ -522,27 +535,6 @@ public async Task CheckIpAddressAsync(string ipAddress) } } - /// - /// Maps an IP filter entity to a DTO - /// - /// The entity to map - /// The mapped DTO - private static IpFilterDto MapToDto(IpFilterEntity entity) - { - return new IpFilterDto - { - Id = entity.Id, - FilterType = entity.FilterType, - IpAddressOrCidr = entity.IpAddressOrCidr, - Description = entity.Description, - IsEnabled = entity.IsEnabled, - CreatedAt = entity.CreatedAt, - UpdatedAt = entity.UpdatedAt, - CreatedBy = entity.CreatedBy, - UpdatedBy = entity.UpdatedBy - }; - } - /// /// Validates if a string is a valid IP address or CIDR notation. /// Delegates to IpAddressHelper for consistent validation. diff --git a/Services/ConduitLLM.Admin/Services/AdminMediaService.cs b/Services/ConduitLLM.Admin/Services/AdminMediaService.cs index d263a2b27..8fc0a221c 100644 --- a/Services/ConduitLLM.Admin/Services/AdminMediaService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminMediaService.cs @@ -12,6 +12,7 @@ public class AdminMediaService : IAdminMediaService { private readonly IMediaRecordRepository _mediaRepository; private readonly IMediaLifecycleService _mediaLifecycleService; + private readonly IMediaStorageService _storageService; private readonly ILogger _logger; /// @@ -19,14 +20,17 @@ public class AdminMediaService : IAdminMediaService /// /// The media record repository. /// The media lifecycle service. + /// The media storage service for S3/R2 operations. /// The logger instance. public AdminMediaService( IMediaRecordRepository mediaRepository, IMediaLifecycleService mediaLifecycleService, + IMediaStorageService storageService, ILogger logger) { _mediaRepository = mediaRepository ?? throw new ArgumentNullException(nameof(mediaRepository)); _mediaLifecycleService = mediaLifecycleService ?? throw new ArgumentNullException(nameof(mediaLifecycleService)); + _storageService = storageService ?? throw new ArgumentNullException(nameof(storageService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -37,11 +41,11 @@ public async Task GetOverallStorageStatsAsync(int? vir { if (virtualKeyGroupId.HasValue) { - _logger.LogInformation("Getting storage statistics for virtual key group {GroupId}", virtualKeyGroupId.Value); + _logger.LogDebug("Getting storage statistics for virtual key group {GroupId}", virtualKeyGroupId.Value); } else { - _logger.LogInformation("Getting overall storage statistics"); + _logger.LogDebug("Getting overall storage statistics"); } return await _mediaLifecycleService.GetOverallStorageStatsAsync(virtualKeyGroupId); } @@ -57,7 +61,7 @@ public async Task GetStorageStatsByVirtualKeyAsync(int virtua { try { - _logger.LogInformation("Getting storage statistics for virtual key {VirtualKeyId}", virtualKeyId); + _logger.LogDebug("Getting storage statistics for virtual key {VirtualKeyId}", virtualKeyId); return await _mediaLifecycleService.GetStorageStatsByVirtualKeyAsync(virtualKeyId); } catch (Exception ex) @@ -72,7 +76,7 @@ public async Task> GetMediaByVirtualKeyAsync(int virtualKeyId) { try { - _logger.LogInformation("Getting media records for virtual key {VirtualKeyId}", virtualKeyId); + _logger.LogDebug("Getting media records for virtual key {VirtualKeyId}", virtualKeyId); return await _mediaLifecycleService.GetMediaByVirtualKeyAsync(virtualKeyId); } catch (Exception ex) @@ -143,8 +147,8 @@ public async Task DeleteMediaAsync(Guid mediaId) { try { - _logger.LogInformation("Deleting media record {MediaId}", mediaId); - + _logger.LogDebug("Deleting media record {MediaId}", mediaId); + var mediaRecord = await _mediaRepository.GetByIdAsync(mediaId); if (mediaRecord == null) { @@ -152,30 +156,34 @@ public async Task DeleteMediaAsync(Guid mediaId) return false; } - // Delete from storage first - try - { - var storageService = _mediaLifecycleService as IMediaStorageService; - if (storageService != null) - { - await storageService.DeleteAsync(mediaRecord.StorageKey); - } - } - catch (Exception storageEx) + // Delete from storage first โ€” abort if this fails to prevent orphaned files + var storageDeleted = await _storageService.DeleteAsync(mediaRecord.StorageKey); + if (!storageDeleted) { - _logger.LogError(storageEx, "Failed to delete media {StorageKey} from storage, continuing with database deletion", mediaRecord.StorageKey); + _logger.LogError( + "Failed to delete media {StorageKey} from storage for record {MediaId}. " + + "Database record will be preserved to prevent orphaned storage.", + mediaRecord.StorageKey, mediaId); + throw new InvalidOperationException( + $"Failed to delete media from storage (key: {mediaRecord.StorageKey}). " + + "The database record was preserved to allow retry."); } - // Delete from database + // Storage succeeded โ€” now delete from database var result = await _mediaRepository.DeleteAsync(mediaId); - + if (result) { - _logger.LogInformation("Successfully deleted media record {MediaId}", mediaId); + _logger.LogInformation("Successfully deleted media record {MediaId} and storage {StorageKey}", + mediaId, mediaRecord.StorageKey); } - + return result; } + catch (InvalidOperationException) + { + throw; + } catch (Exception ex) { _logger.LogError(ex, "Error deleting media record {MediaId}", mediaId); @@ -194,16 +202,10 @@ public async Task> SearchMediaByStorageKeyAsync(string storage } _logger.LogInformation("Searching media by storage key pattern: {Pattern}", storageKeyPattern); - - // Get all media records and filter by pattern - // Note: This is not efficient for large datasets. In production, consider adding a repository method for pattern matching - var allMedia = await _mediaRepository.GetMediaOlderThanAsync(DateTime.UtcNow.AddYears(10)); // Get all - - var matchingMedia = allMedia - .Where(m => m.StorageKey.Contains(storageKeyPattern, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(m => m.CreatedAt) - .ToList(); - + + // Use database-level filtering for efficient pattern matching + var matchingMedia = await _mediaRepository.SearchByStorageKeyPatternAsync(storageKeyPattern); + _logger.LogInformation("Found {Count} media records matching pattern", matchingMedia.Count); return matchingMedia; } @@ -219,7 +221,7 @@ public async Task> GetStorageStatsByProviderAsync() { try { - _logger.LogInformation("Getting storage statistics by provider"); + _logger.LogDebug("Getting storage statistics by provider"); return await _mediaRepository.GetStorageStatsByProviderAsync(); } catch (Exception ex) @@ -234,7 +236,7 @@ public async Task> GetStorageStatsByMediaTypeAsync() { try { - _logger.LogInformation("Getting storage statistics by media type"); + _logger.LogDebug("Getting storage statistics by media type"); return await _mediaRepository.GetStorageStatsByMediaTypeAsync(); } catch (Exception ex) diff --git a/Services/ConduitLLM.Admin/Services/AdminModelCostService.ImportExport.cs b/Services/ConduitLLM.Admin/Services/AdminModelCostService.ImportExport.cs index 05cf03bce..1f223c429 100644 --- a/Services/ConduitLLM.Admin/Services/AdminModelCostService.ImportExport.cs +++ b/Services/ConduitLLM.Admin/Services/AdminModelCostService.ImportExport.cs @@ -3,6 +3,7 @@ using ConduitLLM.Admin.Interfaces; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Core.Events; namespace ConduitLLM.Admin.Services @@ -20,7 +21,7 @@ public async Task ImportModelCostsAsync(IEnumerable mod throw new ArgumentNullException(nameof(modelCosts)); } - if (modelCosts.Count() == 0) + if (!modelCosts.Any()) { return 0; } @@ -28,6 +29,8 @@ public async Task ImportModelCostsAsync(IEnumerable mod try { int importedCount = 0; + int failedCount = 0; + var totalCount = modelCosts.Count(); // Process each model cost foreach (var modelCost in modelCosts) @@ -94,15 +97,16 @@ await PublishEventAsync( } catch (Exception ex) { + failedCount++; _logger.LogWarning(ex, - "Error importing model cost with name '{CostName}'", - LoggingSanitizer.S(modelCost.CostName)); + "Error importing model cost with name '{CostName}'", + LoggingSanitizer.S(modelCost.CostName)); // Continue with next model cost } } - _logger.LogInformation("Imported {Count} model costs", - importedCount); + _logger.LogInformation("Imported {Imported} model costs ({Failed} failed out of {Total})", + importedCount, failedCount, totalCount); return importedCount; } catch (Exception ex) @@ -116,14 +120,20 @@ await PublishEventAsync( /// public async Task ExportModelCostsAsync(string format, int? providerId = null) { + _logger.LogDebug("Exporting model costs as {Format}{ProviderFilter}", + format ?? "json", + providerId.HasValue ? $" for provider {providerId}" : ""); + IEnumerable modelCosts; if (providerId != null) { - modelCosts = await _modelCostRepository.GetByProviderAsync(providerId.Value); + modelCosts = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelCostRepository.GetByProviderPaginatedAsync, providerId.Value); } else { - modelCosts = await _modelCostRepository.GetAllAsync(); + modelCosts = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelCostRepository.GetPaginatedAsync); } format = format?.ToLowerInvariant() ?? "json"; @@ -198,6 +208,8 @@ public async Task ImportModelCostsAsync(string data, string fo { result.FailureCount++; result.Errors.Add($"Failed to import model cost '{modelCost.CostName}': {ex.Message}"); + _logger.LogWarning(ex, "Error importing model cost '{CostName}' from {Format} data", + LoggingSanitizer.S(modelCost.CostName), format); } } } @@ -205,8 +217,11 @@ public async Task ImportModelCostsAsync(string data, string fo { result.FailureCount++; result.Errors.Add($"Failed to parse import data: {ex.Message}"); + _logger.LogError(ex, "Failed to parse {Format} import data", format); } + _logger.LogInformation("Model cost {Format} import completed: {Success} succeeded, {Failed} failed", + format, result.SuccessCount, result.FailureCount); return result; } } diff --git a/Services/ConduitLLM.Admin/Services/AdminModelCostService.Parsers.cs b/Services/ConduitLLM.Admin/Services/AdminModelCostService.Parsers.cs index 032e0aa2b..70e9f1bac 100644 --- a/Services/ConduitLLM.Admin/Services/AdminModelCostService.Parsers.cs +++ b/Services/ConduitLLM.Admin/Services/AdminModelCostService.Parsers.cs @@ -15,6 +15,8 @@ public partial class AdminModelCostService { private string GenerateJsonExport(List modelCosts) { + _logger.LogDebug("Generating JSON export for {Count} model costs", modelCosts.Count); + var exportData = modelCosts.Select(mc => new ModelCostExportDto { CostName = mc.CostName, @@ -38,6 +40,8 @@ private string GenerateJsonExport(List modelCosts) private string GenerateCsvExport(List modelCosts) { + _logger.LogDebug("Generating CSV export for {Count} model costs", modelCosts.Count); + var csv = new StringBuilder(); csv.AppendLine("Cost Name,Pricing Model,Pricing Configuration,Input Cost (per million tokens),Output Cost (per million tokens),Embedding Cost (per million tokens),Batch Processing Multiplier,Supports Batch Processing,Search Unit Cost (per 1K units),Cached Input Cost (per million tokens),Cached Write Cost (per million tokens)"); @@ -66,6 +70,8 @@ private List ParseJsonImport(string jsonData) var importData = JsonSerializer.Deserialize>(jsonData); if (importData == null) return new List(); + _logger.LogDebug("Parsed {Count} model costs from JSON import", importData.Count); + return importData.Select(d => new CreateModelCostDto { CostName = d.CostName, @@ -134,6 +140,9 @@ private List ParseCsvImport(string csvData) } } + _logger.LogDebug("Parsed {Count} model costs from CSV import ({TotalLines} data lines)", + modelCosts.Count, lines.Length - 1); + return modelCosts; } diff --git a/Services/ConduitLLM.Admin/Services/AdminModelCostService.cs b/Services/ConduitLLM.Admin/Services/AdminModelCostService.cs index cc1e1fd7d..22c85c5b0 100644 --- a/Services/ConduitLLM.Admin/Services/AdminModelCostService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminModelCostService.cs @@ -4,6 +4,7 @@ using ConduitLLM.Configuration; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Events; using ConduitLLM.Core.Services; @@ -36,8 +37,8 @@ public AdminModelCostService( IModelCostRepository modelCostRepository, IRequestLogRepository requestLogRepository, IDbContextFactory dbContextFactory, - IPublishEndpoint? publishEndpoint, - ILogger logger) + ILogger logger, + IPublishEndpoint? publishEndpoint = null) : base(publishEndpoint, logger) { _modelCostRepository = modelCostRepository ?? throw new ArgumentNullException(nameof(modelCostRepository)); @@ -70,7 +71,7 @@ public async Task CreateModelCostAsync(CreateModelCostDto modelCos var id = await _modelCostRepository.CreateAsync(modelCostEntity); // Update ModelProviderTypeAssociations to reference this cost if provided - if (modelCost.ModelProviderTypeAssociationIds != null && modelCost.ModelProviderTypeAssociationIds.Count() > 0) + if (modelCost.ModelProviderTypeAssociationIds != null && modelCost.ModelProviderTypeAssociationIds.Any()) { using var dbContext = await _dbContextFactory.CreateDbContextAsync(); @@ -168,7 +169,8 @@ public async Task> GetAllModelCostsAsync() { try { - var modelCosts = await _modelCostRepository.GetAllAsync(); + var modelCosts = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelCostRepository.GetPaginatedAsync); return modelCosts.Select(mc => mc.ToDto()).ToList(); } catch (Exception ex) @@ -226,29 +228,24 @@ public async Task> GetModelCostOverviewAsync(D try { - // Get request logs for the specified time period - var logs = await _requestLogRepository.GetByDateRangeAsync(startDate, endDate); - if (logs == null || logs.Count() == 0) + // Use database-level aggregation instead of loading all logs into memory + var modelAggregations = await _requestLogRepository.GetAggregatedByModelAsync(startDate, endDate); + if (modelAggregations.Count == 0) { return Enumerable.Empty(); } - // Group by model and aggregate cost data - var modelGroups = logs - .Where(l => !string.IsNullOrEmpty(l.ModelName)) // Filter out logs with no model name - .GroupBy(l => l.ModelName) - .Select(g => new ModelCostOverviewDto + return modelAggregations + .Where(m => !string.IsNullOrEmpty(m.ModelName)) + .Select(m => new ModelCostOverviewDto { - Model = g.Key ?? "Unknown", - RequestCount = g.Count(), - TotalCost = g.Sum(l => l.Cost), - InputTokens = g.Sum(l => l.InputTokens), - OutputTokens = g.Sum(l => l.OutputTokens) + Model = m.ModelName, + RequestCount = m.RequestCount, + TotalCost = m.TotalCost, + InputTokens = (int)Math.Min(m.InputTokens, int.MaxValue), + OutputTokens = (int)Math.Min(m.OutputTokens, int.MaxValue) }) - .OrderByDescending(m => m.TotalCost) .ToList(); - - return modelGroups; } catch (Exception ex) { @@ -265,7 +262,8 @@ public async Task> GetModelCostsByProviderAsync(int pr { try { - var modelCosts = await _modelCostRepository.GetByProviderAsync(providerId); + var modelCosts = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelCostRepository.GetByProviderPaginatedAsync, providerId); return modelCosts.Select(mc => mc.ToDto()).ToList(); } catch (Exception ex) @@ -352,7 +350,7 @@ public async Task UpdateModelCostAsync(UpdateModelCostDto modelCost) if (result) { // Publish ModelCostChanged event for cache invalidation and cross-service coordination - if (changedProperties.Count() > 0) + if (changedProperties.Any()) { await PublishEventAsync( new ModelCostChanged diff --git a/Services/ConduitLLM.Admin/Services/AdminModelProviderMappingService.cs b/Services/ConduitLLM.Admin/Services/AdminModelProviderMappingService.cs index 73da60981..641cc1766 100644 --- a/Services/ConduitLLM.Admin/Services/AdminModelProviderMappingService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminModelProviderMappingService.cs @@ -3,13 +3,13 @@ using ConduitLLM.Admin.Interfaces; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Repositories; using ConduitLLM.Core.Events; using ConduitLLM.Core.Services; using MassTransit; - -using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Admin.Services; /// @@ -34,8 +34,8 @@ public AdminModelProviderMappingService( IModelProviderMappingRepository mappingRepository, IProviderRepository providerRepository, IModelRepository modelRepository, - IPublishEndpoint? publishEndpoint, - ILogger logger) + ILogger logger, + IPublishEndpoint? publishEndpoint = null) : base(publishEndpoint, logger) { _mappingRepository = mappingRepository ?? throw new ArgumentNullException(nameof(mappingRepository)); @@ -49,31 +49,31 @@ public AdminModelProviderMappingService( /// public async Task> GetAllMappingsAsync() { - _logger.LogInformation("Getting all model provider mappings"); - return await _mappingRepository.GetAllAsync(); + _logger.LogDebug("Getting all model provider mappings"); + return await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _mappingRepository.GetPaginatedAsync); } /// public async Task GetMappingByIdAsync(int id) { - _logger.LogInformation("Getting model provider mapping with ID: {Id}", id); + _logger.LogDebug("Getting model provider mapping with ID: {Id}", id); return await _mappingRepository.GetByIdAsync(id); } /// public async Task GetMappingByModelIdAsync(int modelId) { - _logger.LogInformation("Getting model provider mapping for model ID: {ModelId}", modelId); - var mappings = await _mappingRepository.GetAllAsync(); - return mappings.FirstOrDefault(m => m.ModelProviderTypeAssociation?.ModelId == modelId); + _logger.LogDebug("Getting model provider mapping for model ID: {ModelId}", modelId); + var mappings = await _mappingRepository.GetByModelIdAsync(modelId); + return mappings.FirstOrDefault(); } /// public async Task> GetMappingsByModelIdAsync(int modelId) { - _logger.LogInformation("Getting all model provider mappings for model ID: {ModelId}", modelId); - var mappings = await _mappingRepository.GetAllAsync(); - return mappings.Where(m => m.ModelProviderTypeAssociation?.ModelId == modelId).ToList(); + _logger.LogDebug("Getting all model provider mappings for model ID: {ModelId}", modelId); + return await _mappingRepository.GetByModelIdAsync(modelId); } /// @@ -81,7 +81,7 @@ public async Task AddMappingAsync(ModelProviderMapping mapping) { try { - _logger.LogInformation("Adding new model provider mapping for model ID: {ModelId}", LoggingSanitizer.S(mapping.ModelAlias)); + _logger.LogDebug("Adding new model provider mapping for model ID: {ModelId}", LoggingSanitizer.S(mapping.ModelAlias)); // Validate provider exists by ID var provider = await _providerRepository.GetByIdAsync(mapping.ProviderId); @@ -105,7 +105,7 @@ public async Task AddMappingAsync(ModelProviderMapping mapping) // Add the mapping await _mappingRepository.CreateAsync(mapping); - + // Publish ModelMappingChanged event for creation await PublishEventAsync( new ModelMappingChanged @@ -119,7 +119,10 @@ await PublishEventAsync( }, $"create model mapping for {mapping.ModelAlias}", new { ModelAlias = mapping.ModelAlias, ProviderId = mapping.ProviderId }); - + + _logger.LogInformation("Created model provider mapping {ModelAlias} -> provider {ProviderId}", + LoggingSanitizer.S(mapping.ModelAlias), mapping.ProviderId); + return true; } catch (Exception ex) @@ -134,7 +137,7 @@ public async Task UpdateMappingAsync(ModelProviderMapping mapping) { try { - _logger.LogInformation("Updating model provider mapping with ID: {Id}", mapping.Id); + _logger.LogDebug("Updating model provider mapping with ID: {Id}", mapping.Id); // Check if the mapping exists var existingMapping = await _mappingRepository.GetByIdAsync(mapping.Id); @@ -179,8 +182,11 @@ await PublishEventAsync( }, $"update model mapping {existingMapping.Id}", new { ModelAlias = existingMapping.ModelAlias, ProviderId = existingMapping.ProviderId }); + + _logger.LogInformation("Updated model provider mapping {MappingId} ({ModelAlias} -> provider {ProviderId})", + existingMapping.Id, LoggingSanitizer.S(existingMapping.ModelAlias), existingMapping.ProviderId); } - + return result; } catch (Exception ex) @@ -195,7 +201,7 @@ public async Task DeleteMappingAsync(int id) { try { - _logger.LogInformation("Deleting model provider mapping with ID: {Id}", id); + _logger.LogDebug("Deleting model provider mapping with ID: {Id}", id); // Check if the mapping exists var existingMapping = await _mappingRepository.GetByIdAsync(id); @@ -223,8 +229,11 @@ await PublishEventAsync( }, $"delete model mapping {existingMapping.Id}", new { ModelAlias = existingMapping.ModelAlias, ProviderId = existingMapping.ProviderId }); + + _logger.LogInformation("Deleted model provider mapping {MappingId} ({ModelAlias})", + existingMapping.Id, LoggingSanitizer.S(existingMapping.ModelAlias)); } - + return result; } catch (Exception ex) @@ -239,8 +248,9 @@ public async Task> GetProvidersAsync() { try { - _logger.LogInformation("Getting all providers"); - return await _providerRepository.GetAllAsync(); + _logger.LogDebug("Getting all providers"); + return await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _providerRepository.GetPaginatedAsync); } catch (Exception ex) { @@ -252,16 +262,18 @@ public async Task> GetProvidersAsync() /// public async Task<(IEnumerable created, IEnumerable errors)> CreateBulkMappingsAsync(IEnumerable mappings) { - _logger.LogInformation("Creating bulk model provider mappings"); + _logger.LogDebug("Creating bulk model provider mappings"); var created = new List(); var errors = new List(); var mappingsList = mappings.ToList(); // Pre-load all providers and existing mappings to avoid N+1 queries - var allProviders = await _providerRepository.GetAllAsync(); + var allProviders = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _providerRepository.GetPaginatedAsync); var providerLookup = allProviders.ToDictionary(p => p.Id, p => p); - var allMappings = await _mappingRepository.GetAllAsync(); + var allMappings = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _mappingRepository.GetPaginatedAsync); var existingMappingsLookup = allMappings.ToDictionary(m => m.ModelAlias.ToLowerInvariant(), m => m); // Pre-load all models with details for API parameter merging diff --git a/Services/ConduitLLM.Admin/Services/AdminNotificationService.cs b/Services/ConduitLLM.Admin/Services/AdminNotificationService.cs index dc0ae780e..9b78c9a22 100644 --- a/Services/ConduitLLM.Admin/Services/AdminNotificationService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminNotificationService.cs @@ -1,7 +1,7 @@ using ConduitLLM.Admin.Extensions; using ConduitLLM.Admin.Interfaces; using ConduitLLM.Configuration.DTOs; - +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Admin.Services { @@ -35,24 +35,20 @@ public async Task> GetAllNotificationsAsync() { try { - _logger.LogInformation("Getting all notifications"); + _logger.LogDebug("Getting all notifications"); - var notifications = await _notificationRepository.GetAllAsync(); + var notifications = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _notificationRepository.GetPaginatedAsync); var virtualKeyIds = notifications .Where(n => n.VirtualKeyId.HasValue) .Select(n => n.VirtualKeyId!.Value) .Distinct() .ToList(); - // Get virtual key names for the notifications - var virtualKeys = new Dictionary(); - if (virtualKeyIds.Count() > 0) - { - var keys = await _virtualKeyRepository.GetAllAsync(); - virtualKeys = keys - .Where(k => virtualKeyIds.Contains(k.Id)) - .ToDictionary(k => k.Id, k => k.KeyName); - } + // Get virtual key names for the notifications using efficient lookup + var virtualKeys = virtualKeyIds.Count != 0 + ? await _virtualKeyRepository.GetKeyNamesByIdsAsync(virtualKeyIds) + : new Dictionary(); // Map to DTOs with virtual key names var result = notifications @@ -83,24 +79,20 @@ public async Task> GetUnreadNotificationsAsync() { try { - _logger.LogInformation("Getting unread notifications"); + _logger.LogDebug("Getting unread notifications"); - var notifications = await _notificationRepository.GetUnreadAsync(); + var notifications = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _notificationRepository.GetUnreadPaginatedAsync); var virtualKeyIds = notifications .Where(n => n.VirtualKeyId.HasValue) .Select(n => n.VirtualKeyId!.Value) .Distinct() .ToList(); - // Get virtual key names for the notifications - var virtualKeys = new Dictionary(); - if (virtualKeyIds.Count() > 0) - { - var keys = await _virtualKeyRepository.GetAllAsync(); - virtualKeys = keys - .Where(k => virtualKeyIds.Contains(k.Id)) - .ToDictionary(k => k.Id, k => k.KeyName); - } + // Get virtual key names for the notifications using efficient lookup + var virtualKeys = virtualKeyIds.Count != 0 + ? await _virtualKeyRepository.GetKeyNamesByIdsAsync(virtualKeyIds) + : new Dictionary(); // Map to DTOs with virtual key names var result = notifications @@ -131,7 +123,7 @@ public async Task> GetUnreadNotificationsAsync() { try { - _logger.LogInformation("Getting notification with ID: {Id}", id); + _logger.LogDebug("Getting notification with ID: {Id}", id); var notification = await _notificationRepository.GetByIdAsync(id); if (notification == null) @@ -162,7 +154,7 @@ public async Task CreateNotificationAsync(CreateNotificationDto { try { - _logger.LogInformation("Creating new notification"); + _logger.LogDebug("Creating new notification"); // Validate virtual key ID if provided if (notification.VirtualKeyId.HasValue) @@ -195,6 +187,9 @@ public async Task CreateNotificationAsync(CreateNotificationDto keyName = key?.KeyName; } + _logger.LogInformation("Created notification {NotificationId} of type {Type}", + createdNotification.Id, createdNotification.Type); + return createdNotification.ToDto(keyName); } catch (Exception ex) @@ -209,7 +204,7 @@ public async Task UpdateNotificationAsync(UpdateNotificationDto notificati { try { - _logger.LogInformation("Updating notification with ID: {Id}", notification.Id); + _logger.LogDebug("Updating notification with ID: {Id}", notification.Id); // Get the existing notification var existingNotification = await _notificationRepository.GetByIdAsync(notification.Id); @@ -241,7 +236,7 @@ public async Task MarkNotificationAsReadAsync(int id) { try { - _logger.LogInformation("Marking notification with ID {Id} as read", id); + _logger.LogDebug("Marking notification with ID {Id} as read", id); return await _notificationRepository.MarkAsReadAsync(id); } @@ -257,17 +252,19 @@ public async Task MarkAllNotificationsAsReadAsync() { try { - _logger.LogInformation("Marking all notifications as read"); + _logger.LogDebug("Marking all notifications as read"); // Get all unread notifications - var unreadNotifications = await _notificationRepository.GetUnreadAsync(); - if (unreadNotifications.Count() == 0) + var unreadNotifications = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _notificationRepository.GetUnreadPaginatedAsync); + if (!unreadNotifications.Any()) { return 0; } // Mark each as read int count = 0; + int failed = 0; foreach (var notification in unreadNotifications) { var success = await _notificationRepository.MarkAsReadAsync(notification.Id); @@ -275,6 +272,20 @@ public async Task MarkAllNotificationsAsReadAsync() { count++; } + else + { + failed++; + } + } + + if (failed > 0) + { + _logger.LogWarning("MarkAllNotificationsAsRead: {Marked} marked, {Failed} failed out of {Total}", + count, failed, unreadNotifications.Count); + } + else if (count > 0) + { + _logger.LogInformation("Marked {Count} notifications as read", count); } return count; @@ -291,9 +302,14 @@ public async Task DeleteNotificationAsync(int id) { try { - _logger.LogInformation("Deleting notification with ID: {Id}", id); + _logger.LogDebug("Deleting notification with ID: {Id}", id); - return await _notificationRepository.DeleteAsync(id); + var result = await _notificationRepository.DeleteAsync(id); + if (result) + { + _logger.LogInformation("Deleted notification {NotificationId}", id); + } + return result; } catch (Exception ex) { diff --git a/Services/ConduitLLM.Admin/Services/AdminOperationsMetricsService.cs b/Services/ConduitLLM.Admin/Services/AdminOperationsMetricsService.cs index 3d0e176d7..3c151b710 100644 --- a/Services/ConduitLLM.Admin/Services/AdminOperationsMetricsService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminOperationsMetricsService.cs @@ -82,17 +82,6 @@ public class AdminOperationsMetricsService : BackgroundService LabelNames = new[] { "entity_type", "change_type" } // entity_type: virtualkey, provider, mapping }); - // Admin API usage metrics - private static readonly Counter AdminApiAuthentications = Prometheus.Metrics - .CreateCounter("conduit_admin_authentications_total", "Total authentication attempts", - new CounterConfiguration - { - LabelNames = new[] { "status" } // status: success, failed - }); - - private static readonly Gauge ActiveAdminSessions = Prometheus.Metrics - .CreateGauge("conduit_admin_sessions_active", "Number of active admin sessions"); - // CSV import/export metrics private static readonly Counter CsvOperations = Prometheus.Metrics .CreateCounter("conduit_admin_csv_operations_total", "Total CSV operations", @@ -136,7 +125,10 @@ public AdminOperationsMetricsService( /// A task that represents the asynchronous operation. protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Admin operations metrics service starting..."); + _logger.LogInformation("AdminOperationsMetricsService starting with collection interval {Interval}", _collectionInterval); + + // Brief delay to let other services initialize first + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); while (!stoppingToken.IsCancellationRequested) { @@ -144,6 +136,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await CollectMetricsAsync(); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } catch (Exception ex) { _logger.LogError(ex, "Error collecting admin operations metrics"); @@ -152,11 +148,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await Task.Delay(_collectionInterval, stoppingToken); } - _logger.LogInformation("Admin operations metrics service stopped"); + _logger.LogInformation("AdminOperationsMetricsService stopped"); } private async Task CollectMetricsAsync() { + var sw = System.Diagnostics.Stopwatch.StartNew(); using var scope = _serviceProvider.CreateScope(); var tasks = new[] @@ -167,6 +164,16 @@ private async Task CollectMetricsAsync() }; await Task.WhenAll(tasks); + sw.Stop(); + + if (sw.ElapsedMilliseconds > 5000) + { + _logger.LogWarning("Slow admin metrics collection: took {ElapsedMs}ms (threshold: 5000ms)", sw.ElapsedMilliseconds); + } + else + { + _logger.LogDebug("Admin metrics collection completed in {ElapsedMs}ms", sw.ElapsedMilliseconds); + } } private async Task CollectVirtualKeyMetrics(IServiceScope scope) @@ -174,32 +181,24 @@ private async Task CollectVirtualKeyMetrics(IServiceScope scope) try { var virtualKeyRepo = scope.ServiceProvider.GetRequiredService(); - var allKeys = await virtualKeyRepo.GetAllAsync(); - var now = DateTime.UtcNow; - var activeCount = 0; - var disabledCount = 0; - var expiredCount = 0; + // Use database-level count for active keys + var activeCount = await virtualKeyRepo.CountActiveAsync(); - foreach (var key in allKeys) - { - if (!key.IsEnabled) - { - disabledCount++; - } - else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < now) - { - expiredCount++; - } - else - { - activeCount++; - } - } + // Get total count via pagination (just need count, not items) + var (_, totalCount) = await virtualKeyRepo.GetPaginatedAsync(1, 1); + + // Calculate disabled and expired from total + // Note: This is an approximation - for precise counts, add dedicated count methods + var nonActiveCount = totalCount - activeCount; TotalVirtualKeys.WithLabels("active").Set(activeCount); - TotalVirtualKeys.WithLabels("disabled").Set(disabledCount); - TotalVirtualKeys.WithLabels("expired").Set(expiredCount); + TotalVirtualKeys.WithLabels("disabled").Set(nonActiveCount); + TotalVirtualKeys.WithLabels("expired").Set(0); // Expired keys are included in non-active count + + _logger.LogDebug( + "Virtual key metrics: {ActiveCount} active, {NonActiveCount} non-active", + activeCount, nonActiveCount); } catch (Exception ex) { @@ -212,15 +211,18 @@ private async Task CollectProviderMetrics(IServiceScope scope) try { var providerRepository = scope.ServiceProvider.GetRequiredService(); - var providers = await providerRepository.GetAllAsync(); - // Count total enabled and disabled providers - var enabledCount = providers.Count(p => p.IsEnabled); - var disabledCount = providers.Count(p => !p.IsEnabled); + // Use database-level counts instead of loading all providers + var enabledCount = await providerRepository.CountAsync(enabledOnly: true); + var disabledCount = await providerRepository.CountAsync(enabledOnly: false); // Use simple enabled/disabled labels instead of provider types ConfiguredProviders.WithLabels("all", "true").Set(enabledCount); ConfiguredProviders.WithLabels("all", "false").Set(disabledCount); + + _logger.LogDebug( + "Provider metrics: {EnabledCount} enabled, {DisabledCount} disabled", + enabledCount, disabledCount); } catch (Exception ex) { @@ -240,6 +242,8 @@ private async Task CollectModelMappingMetrics(IServiceScope scope) // Use a simple "total" label instead of provider-specific labels ActiveModelMappings.WithLabels("total").Set(totalMappings); + + _logger.LogDebug("Model mapping metrics: {TotalMappings} active mappings", totalMappings); } catch (Exception ex) { @@ -299,24 +303,6 @@ public static void RecordConfigurationChange(string entityType, string changeTyp ConfigurationChanges.WithLabels(entityType, changeType).Inc(); } - /// - /// Records an authentication attempt metric. - /// - /// The authentication status (e.g., success, failure). - public static void RecordAuthentication(string status) - { - AdminApiAuthentications.WithLabels(status).Inc(); - } - - /// - /// Sets the current count of active admin sessions. - /// - /// The number of active sessions. - public static void SetActiveSessions(int count) - { - ActiveAdminSessions.Set(count); - } - /// /// Records a CSV operation metric. /// diff --git a/Services/ConduitLLM.Admin/Services/AdminSystemInfoService.cs b/Services/ConduitLLM.Admin/Services/AdminSystemInfoService.cs index 0e91574d3..33aa7f452 100644 --- a/Services/ConduitLLM.Admin/Services/AdminSystemInfoService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminSystemInfoService.cs @@ -41,7 +41,7 @@ public AdminSystemInfoService( /// public async Task GetSystemInfoAsync() { - _logger.LogInformation("Getting system information"); + _logger.LogDebug("Getting system information"); var systemInfo = new SystemInfoDto { @@ -58,7 +58,7 @@ public async Task GetSystemInfoAsync() /// public async Task GetHealthStatusAsync() { - _logger.LogInformation("Getting health status"); + _logger.LogDebug("Getting health status"); var sw = Stopwatch.StartNew(); var checks = new Dictionary(); @@ -150,6 +150,7 @@ private async Task GetDatabaseInfoAsync() { // Check connection info.Connected = await _dbContext.GetDatabase().CanConnectAsync(); + _logger.LogDebug("Database connection check: {Connected}, provider: {Provider}", info.Connected, info.Provider); // Get database version if possible if (info.Connected) @@ -219,7 +220,7 @@ private async Task GetDatabaseInfoAsync() } catch (Exception ex) { - _logger.LogWarning(ex, "Could not get PostgreSQL database size"); + _logger.LogWarning(ex, "Could not get PostgreSQL version/size for host {Host}", info.Location); info.Size = "N/A"; } } @@ -253,19 +254,27 @@ private async Task CheckDatabaseHealthAsync() if (canConnect) { // Check migrations - bool pendingMigrations = (await _dbContext.GetDatabase().GetPendingMigrationsAsync()).Count() > 0; + var pendingMigrationsList = (await _dbContext.GetDatabase().GetPendingMigrationsAsync()).ToList(); + bool pendingMigrations = pendingMigrationsList.Count > 0; // Get migration history - var migrations = await _dbContext.GetDatabase().GetAppliedMigrationsAsync(); + var migrations = (await _dbContext.GetDatabase().GetAppliedMigrationsAsync()).ToList(); health.Status = pendingMigrations ? "degraded" : "healthy"; if (pendingMigrations) { + _logger.LogWarning("Database has {PendingCount} pending migrations (applied: {AppliedCount})", + pendingMigrationsList.Count, migrations.Count); health.Description = "Database connected but has pending migrations"; } + else + { + _logger.LogDebug("Database healthy with {AppliedCount} applied migrations", migrations.Count); + } } else { + _logger.LogWarning("Database health check failed: unable to connect"); health.Status = "unhealthy"; health.Description = "Database connection failed"; } @@ -292,6 +301,9 @@ private async Task GetRecordCountsAsync() counts.Settings = await _dbContext.GlobalSettings.CountAsync(); counts.Providers = await _dbContext.Providers.CountAsync(); counts.ModelMappings = await _dbContext.ModelProviderMappings.CountAsync(); + + _logger.LogDebug("Record counts: VirtualKeys={VirtualKeys}, Requests={Requests}, Providers={Providers}, Mappings={Mappings}", + counts.VirtualKeys, counts.Requests, counts.Providers, counts.ModelMappings); } catch (Exception ex) { @@ -356,65 +368,74 @@ FROM sqlite_master } } - private string MaskConnectionString(string? connectionString) + /// + /// Parses a connection string into a case-insensitive dictionary of key-value pairs. + /// Handles values containing '=' correctly by limiting the split. + /// + private static Dictionary ParseConnectionStringParts(string? connectionString) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(connectionString)) + return result; + + foreach (var part in connectionString.Split(';')) + { + var trimmed = part.Trim(); + if (string.IsNullOrEmpty(trimmed)) + continue; + + var kvp = trimmed.Split('=', 2); + if (kvp.Length == 2) + { + result[kvp[0].Trim()] = kvp[1].Trim(); + } + } + + return result; + } + + private static string MaskConnectionString(string? connectionString) { if (string.IsNullOrEmpty(connectionString)) return "Not configured"; - var parts = connectionString.Split(';'); - var maskedParts = new List(); + var parts = ParseConnectionStringParts(connectionString); + var maskedParts = new List(parts.Count); - foreach (var part in parts) + foreach (var kvp in parts) { - var trimmedPart = part.Trim(); - if (trimmedPart.StartsWith("Password=", StringComparison.OrdinalIgnoreCase) || - trimmedPart.StartsWith("Pwd=", StringComparison.OrdinalIgnoreCase)) + if (kvp.Key.Equals("Password", StringComparison.OrdinalIgnoreCase) || + kvp.Key.Equals("Pwd", StringComparison.OrdinalIgnoreCase)) { - maskedParts.Add(trimmedPart.Split('=')[0] + "=****"); + maskedParts.Add($"{kvp.Key}=****"); } else { - maskedParts.Add(trimmedPart); + maskedParts.Add($"{kvp.Key}={kvp.Value}"); } } return string.Join("; ", maskedParts); } - - private string ExtractHostFromConnectionString(string? connectionString) + private static string ExtractHostFromConnectionString(string? connectionString) { - if (string.IsNullOrEmpty(connectionString)) - return "Unknown"; + var parts = ParseConnectionStringParts(connectionString); - var parts = connectionString.Split(';'); - foreach (var part in parts) - { - var trimmedPart = part.Trim(); - if (trimmedPart.StartsWith("Host=", StringComparison.OrdinalIgnoreCase) || - trimmedPart.StartsWith("Server=", StringComparison.OrdinalIgnoreCase)) - { - return trimmedPart.Split('=')[1].Trim(); - } - } + if (parts.TryGetValue("Host", out var host)) + return host; + if (parts.TryGetValue("Server", out var server)) + return server; return "Unknown"; } - private string ExtractDatabaseNameFromConnectionString(string? connectionString) + private static string ExtractDatabaseNameFromConnectionString(string? connectionString) { - if (string.IsNullOrEmpty(connectionString)) - return ""; + var parts = ParseConnectionStringParts(connectionString); - var parts = connectionString.Split(';'); - foreach (var part in parts) - { - var trimmedPart = part.Trim(); - if (trimmedPart.StartsWith("Database=", StringComparison.OrdinalIgnoreCase)) - { - return trimmedPart.Split('=')[1].Trim(); - } - } + if (parts.TryGetValue("Database", out var database)) + return database; return ""; } diff --git a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Discovery.cs b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Discovery.cs index 6c1945a01..a2a0355a8 100644 --- a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Discovery.cs +++ b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Discovery.cs @@ -23,43 +23,59 @@ public partial class AdminVirtualKeyService return null; } - // Get all enabled model mappings with their related data + // Project to only the fields the loop reads โ€” no full Provider/Model/Series graphs. + // Inner Caps is null when the mapping is missing its Model row, which preserves + // the existing "no model data" warning below. using var context = await _dbContextFactory.CreateDbContextAsync(); - - var modelMappings = await context.ModelProviderMappings - .Include(m => m.Provider) - .Include(m => m.ModelProviderTypeAssociation) - .ThenInclude(mpta => mpta.Model) - .ThenInclude(m => m.Series) + + var projections = await context.ModelProviderMappings + .AsNoTracking() .Where(m => m.IsEnabled && m.Provider != null && m.Provider.IsEnabled) + .Select(m => new + { + m.ModelAlias, + Caps = m.ModelProviderTypeAssociation != null && m.ModelProviderTypeAssociation.Model != null + ? new + { + m.ModelProviderTypeAssociation.Model.SupportsChat, + m.ModelProviderTypeAssociation.Model.SupportsStreaming, + m.ModelProviderTypeAssociation.Model.SupportsVision, + m.ModelProviderTypeAssociation.Model.SupportsVideoGeneration, + m.ModelProviderTypeAssociation.Model.SupportsImageGeneration, + m.ModelProviderTypeAssociation.Model.SupportsEmbeddings, + m.ModelProviderTypeAssociation.Model.SupportsFunctionCalling, + m.ModelProviderTypeAssociation.Model.Description, + m.ModelProviderTypeAssociation.Model.ModelCardUrl, + m.ModelProviderTypeAssociation.Model.MaxInputTokens, + m.ModelProviderTypeAssociation.Model.MaxOutputTokens, + m.ModelProviderTypeAssociation.Model.TokenizerType + } + : null + }) .ToListAsync(); var models = new List(); - foreach (var mapping in modelMappings) + foreach (var p in projections) { - // Skip if model is missing - if (mapping.ModelProviderTypeAssociation?.Model == null) + if (p.Caps == null) { - _logger.LogWarning("Model mapping {ModelAlias} has no model data", mapping.ModelAlias); + _logger.LogWarning("Model mapping {ModelAlias} has no model data", p.ModelAlias); continue; } - var caps = mapping.ModelProviderTypeAssociation.Model; - - // Apply capability filter if specified if (!string.IsNullOrEmpty(capability)) { var capabilityKey = capability.Replace("-", "_").ToLowerInvariant(); bool hasCapability = capabilityKey switch { - "chat" => caps.SupportsChat, - "streaming" or "chat_stream" => caps.SupportsStreaming, - "vision" => caps.SupportsVision, - "video_generation" => caps.SupportsVideoGeneration, - "image_generation" => caps.SupportsImageGeneration, - "embeddings" => caps.SupportsEmbeddings, - "function_calling" => caps.SupportsFunctionCalling, + "chat" => p.Caps.SupportsChat, + "streaming" or "chat_stream" => p.Caps.SupportsStreaming, + "vision" => p.Caps.SupportsVision, + "video_generation" => p.Caps.SupportsVideoGeneration, + "image_generation" => p.Caps.SupportsImageGeneration, + "embeddings" => p.Caps.SupportsEmbeddings, + "function_calling" => p.Caps.SupportsFunctionCalling, _ => false }; @@ -69,33 +85,28 @@ public partial class AdminVirtualKeyService } } - // Build flat capabilities structure matching DiscoveryController var capabilities = new Dictionary { - ["supports_chat"] = caps.SupportsChat, - ["supports_streaming"] = caps.SupportsStreaming, - ["supports_vision"] = caps.SupportsVision, - ["supports_function_calling"] = caps.SupportsFunctionCalling, - ["supports_video_generation"] = caps.SupportsVideoGeneration, - ["supports_image_generation"] = caps.SupportsImageGeneration, - ["supports_embeddings"] = caps.SupportsEmbeddings + ["supports_chat"] = p.Caps.SupportsChat, + ["supports_streaming"] = p.Caps.SupportsStreaming, + ["supports_vision"] = p.Caps.SupportsVision, + ["supports_function_calling"] = p.Caps.SupportsFunctionCalling, + ["supports_video_generation"] = p.Caps.SupportsVideoGeneration, + ["supports_image_generation"] = p.Caps.SupportsImageGeneration, + ["supports_embeddings"] = p.Caps.SupportsEmbeddings, + ["description"] = p.Caps.Description ?? "", + ["model_card_url"] = p.Caps.ModelCardUrl ?? "", + ["input_tokens"] = p.Caps.MaxInputTokens ?? 0, + ["output_tokens"] = p.Caps.MaxOutputTokens ?? 0, + ["tokenizer_type"] = p.Caps.TokenizerType.ToString().ToLowerInvariant() }; - // Add metadata - capabilities["description"] = mapping.ModelProviderTypeAssociation.Model.Description ?? ""; - capabilities["model_card_url"] = mapping.ModelProviderTypeAssociation.Model.ModelCardUrl ?? ""; - capabilities["input_tokens"] = mapping.ModelProviderTypeAssociation.Model.MaxInputTokens ?? 0; - capabilities["output_tokens"] = mapping.ModelProviderTypeAssociation.Model.MaxOutputTokens ?? 0; - capabilities["tokenizer_type"] = mapping.ModelProviderTypeAssociation.Model.TokenizerType.ToString().ToLowerInvariant(); - - var model = new DiscoveredModelDto + models.Add(new DiscoveredModelDto { - Id = mapping.ModelAlias, - DisplayName = mapping.ModelAlias, + Id = p.ModelAlias, + DisplayName = p.ModelAlias, Capabilities = capabilities - }; - - models.Add(model); + }); } return new VirtualKeyDiscoveryPreviewDto diff --git a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Usage.cs b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Usage.cs index 13623ab84..13a123278 100644 --- a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Usage.cs +++ b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Usage.cs @@ -1,7 +1,11 @@ +using ConduitLLM.Admin.Extensions; using ConduitLLM.Core.Extensions; using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.DTOs.VirtualKey; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; +using Microsoft.EntityFrameworkCore; +using VirtualKeyUtilities = ConduitLLM.Configuration.Utilities.VirtualKeyUtilities; namespace ConduitLLM.Admin.Services { @@ -24,37 +28,38 @@ public async Task PerformMaintenanceAsync() try { - // Get all virtual keys - var allKeys = await _virtualKeyRepository.GetAllAsync(); - _logger.LogInformation("Processing maintenance for {KeyCount} virtual keys", allKeys.Count()); + var now = DateTime.UtcNow; - int keysDisabled = 0; + await using var context = await _dbContextFactory.CreateDbContextAsync(); - foreach (var key in allKeys) + // Capture identifying fields of the keys we're about to disable so the per-key + // audit log lines below have something to reference. Same predicate as the + // UPDATE; modulo a tiny race with concurrent disables, the lists agree. + var expiredKeys = await context.VirtualKeys + .AsNoTracking() + .Where(vk => vk.IsEnabled && vk.ExpiresAt != null && vk.ExpiresAt < now) + .Select(vk => new { vk.Id, vk.KeyName }) + .ToListAsync(); + + if (expiredKeys.Count == 0) + { + _logger.LogInformation("Virtual key maintenance completed. No expired keys to disable."); + return; + } + + _logger.LogInformation("Processing maintenance for {KeyCount} expired virtual keys", expiredKeys.Count); + + // Disable all matching keys in a single SQL UPDATE rather than N round-trips. + var keysDisabled = await context.VirtualKeys + .Where(vk => vk.IsEnabled && vk.ExpiresAt != null && vk.ExpiresAt < now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(vk => vk.IsEnabled, false) + .SetProperty(vk => vk.UpdatedAt, now)); + + foreach (var key in expiredKeys) { - try - { - // Budget resets are no longer performed in the bank account model - // Only check and disable expired keys - if (key.IsEnabled && key.ExpiresAt.HasValue && key.ExpiresAt.Value < DateTime.UtcNow) - { - key.IsEnabled = false; - key.UpdatedAt = DateTime.UtcNow; - - var updated = await _virtualKeyRepository.UpdateAsync(key); - if (updated) - { - keysDisabled++; - _logger.LogInformation("Disabled expired virtual key {KeyId} ({KeyName})", - key.Id, LoggingSanitizer.S(key.KeyName)); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing maintenance for virtual key {KeyId}", key.Id); - // Continue processing other keys even if one fails - } + _logger.LogInformation("Disabled expired virtual key {KeyId} ({KeyName})", + key.Id, LoggingSanitizer.S(key.KeyName)); } _logger.LogInformation("Virtual key maintenance completed. Keys disabled: {KeysDisabled}", keysDisabled); @@ -74,7 +79,7 @@ public async Task PerformMaintenanceAsync() { return null; } - return MapToDto(key); + return VirtualKeyUtilities.MapToDto(key); } /// @@ -116,7 +121,7 @@ public async Task PerformMaintenanceAsync() } // Hash the key for lookup - var keyHash = ComputeSha256Hash(keyValue); + var keyHash = VirtualKeyUtilities.HashKey(keyValue); // Get the virtual key by hash var virtualKey = await _virtualKeyRepository.GetByKeyHashAsync(keyHash); @@ -163,48 +168,5 @@ public async Task PerformMaintenanceAsync() }; } - /// - /// Maps a VirtualKey entity to a VirtualKeyDto - /// - /// The entity to map - /// The mapped DTO - private static VirtualKeyDto MapToDto(VirtualKey key) - { - return new VirtualKeyDto - { - Id = key.Id, - KeyName = key.KeyName, - KeyPrefix = GenerateKeyPrefix(key.KeyHash), - AllowedModels = key.AllowedModels, - VirtualKeyGroupId = key.VirtualKeyGroupId, - IsEnabled = key.IsEnabled, - ExpiresAt = key.ExpiresAt, - CreatedAt = key.CreatedAt, - UpdatedAt = key.UpdatedAt, - Metadata = key.Metadata, - RateLimitRpm = key.RateLimitRpm, - RateLimitRpd = key.RateLimitRpd - }; - } - - /// - /// Generates a key prefix for display purposes - /// - /// The key hash - /// A prefix showing part of the key - private static string GenerateKeyPrefix(string keyHash) - { - // Handle null or empty keyHash to prevent exceptions in tests - if (string.IsNullOrEmpty(keyHash)) - { - return "condt_******..."; - } - - // Generate a prefix like "condt_abc123..." from the hash - // This is for display purposes only - var prefixLength = Math.Min(6, keyHash.Length); - var shortPrefix = keyHash.Substring(0, prefixLength).ToLower(); - return $"condt_{shortPrefix}..."; - } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Validation.cs b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Validation.cs index 1e22f8bf3..b686c1455 100644 --- a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Validation.cs +++ b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.Validation.cs @@ -1,9 +1,9 @@ using ConduitLLM.Core.Extensions; -using System.Security.Cryptography; -using System.Text; +using ConduitLLM.Core.Services; using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.DTOs.VirtualKey; +using VirtualKeyUtilities = ConduitLLM.Configuration.Utilities.VirtualKeyUtilities; namespace ConduitLLM.Admin.Services { @@ -32,134 +32,82 @@ public async Task ValidateVirtualKeyAsync(string key } // Hash the key for lookup - string keyHash = ComputeSha256Hash(key); + string keyHash = VirtualKeyUtilities.HashKey(key); - // Look up the key in the database - var virtualKey = await _virtualKeyRepository.GetByKeyHashAsync(keyHash); - if (virtualKey == null) + try { - result.ErrorMessage = "Key not found"; - return result; - } - - // Check if key is enabled - if (!virtualKey.IsEnabled) - { - result.ErrorMessage = "Key is disabled"; - return result; - } - - // Check expiration - if (virtualKey.ExpiresAt.HasValue && virtualKey.ExpiresAt.Value < DateTime.UtcNow) - { - result.ErrorMessage = "Key has expired"; - return result; - } - - // Check group balance - var group = await _groupRepository.GetByKeyIdAsync(virtualKey.Id); - if (group != null && group.Balance <= 0) - { - result.ErrorMessage = "Budget depleted"; - return result; - } - - // Check if model is allowed (if specified) - if (!string.IsNullOrEmpty(requestedModel) && !string.IsNullOrEmpty(virtualKey.AllowedModels)) - { - bool isModelAllowed = IsModelAllowed(requestedModel, virtualKey.AllowedModels); - - if (!isModelAllowed) + // Look up the key in the database + var virtualKey = await _virtualKeyRepository.GetByKeyHashAsync(keyHash); + if (virtualKey == null) { - result.ErrorMessage = $"Model {requestedModel} is not allowed for this key"; + result.ErrorMessage = "Key not found"; return result; } - } - // All validations passed - result.IsValid = true; - result.VirtualKeyId = virtualKey.Id; - result.KeyName = virtualKey.KeyName; - result.AllowedModels = virtualKey.AllowedModels; - // Budget info is now at group level, not included in validation result + // Delegate core validation to shared helper + var validationResult = await VirtualKeyValidationHelper.ValidateVirtualKeyAsync( + virtualKey, requestedModel, checkBalance: true, _groupRepository, _logger); - return result; - } + if (!validationResult.IsValid) + { + // Map helper reasons to admin-specific error messages + result.ErrorMessage = validationResult.Reason switch + { + "Insufficient balance" => "Budget depleted", + "Model not allowed" when !string.IsNullOrEmpty(requestedModel) + => $"Model {requestedModel} is not allowed for this key", + _ => validationResult.Reason + }; + return result; + } - /// - public async Task GetValidationInfoAsync(int id) - { - _logger.LogInformation("Getting validation info for virtual key ID {KeyId}", id); + // All validations passed + result.IsValid = true; + result.VirtualKeyId = virtualKey.Id; + result.KeyName = virtualKey.KeyName; + result.AllowedModels = virtualKey.AllowedModels; + // Budget info is now at group level, not included in validation result - var key = await _virtualKeyRepository.GetByIdAsync(id); - if (key == null) - { - return null; + return result; } - - return new VirtualKeyValidationInfoDto - { - Id = key.Id, - KeyName = key.KeyName, - AllowedModels = key.AllowedModels, - VirtualKeyGroupId = key.VirtualKeyGroupId, - IsEnabled = key.IsEnabled, - ExpiresAt = key.ExpiresAt, - RateLimitRpm = key.RateLimitRpm, - RateLimitRpd = key.RateLimitRpd - }; - } - - /// - /// Computes a SHA256 hash of the input string - /// - /// The input to hash - /// The hash as a hexadecimal string - private static string ComputeSha256Hash(string input) - { - using var sha256 = SHA256.Create(); - byte[] bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); - - var builder = new StringBuilder(); - foreach (byte b in bytes) + catch (Exception ex) { - builder.Append(b.ToString("x2")); + _logger.LogError(ex, "Failed to validate virtual key for model {Model}", LoggingSanitizer.S(requestedModel ?? "any")); + result.ErrorMessage = "Validation failed due to an internal error"; + return result; } - - return builder.ToString(); } - /// - /// Checks if a requested model is allowed based on the AllowedModels string - /// - /// The model being requested - /// Comma-separated string of allowed models - /// True if the model is allowed, false otherwise - private static bool IsModelAllowed(string requestedModel, string allowedModels) + /// + public async Task GetValidationInfoAsync(int id) { - if (string.IsNullOrEmpty(allowedModels)) - return true; // No restrictions - - var allowedModelsList = allowedModels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - // First check for exact match - if (allowedModelsList.Any(m => string.Equals(m, requestedModel, StringComparison.OrdinalIgnoreCase))) - return true; + _logger.LogDebug("Getting validation info for virtual key ID {KeyId}", id); - // Then check for wildcard/prefix matches - foreach (var allowedModel in allowedModelsList) + try { - // Handle wildcards like "gpt-4*" to match any GPT-4 model - if (allowedModel.EndsWith("*", StringComparison.OrdinalIgnoreCase) && - allowedModel.Length > 1) + var key = await _virtualKeyRepository.GetByIdAsync(id); + if (key == null) { - string prefix = allowedModel.Substring(0, allowedModel.Length - 1); - if (requestedModel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return true; + return null; } - } - return false; + return new VirtualKeyValidationInfoDto + { + Id = key.Id, + KeyName = key.KeyName, + AllowedModels = key.AllowedModels, + VirtualKeyGroupId = key.VirtualKeyGroupId, + IsEnabled = key.IsEnabled, + ExpiresAt = key.ExpiresAt, + RateLimitRpm = key.RateLimitRpm, + RateLimitRpd = key.RateLimitRpd + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve validation info for virtual key ID {KeyId}", id); + throw; + } } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.cs b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.cs index bd057b400..78a5df73b 100644 --- a/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.cs +++ b/Services/ConduitLLM.Admin/Services/AdminVirtualKeyService.cs @@ -1,15 +1,12 @@ -using ConduitLLM.Core.Extensions; -using System.Security.Cryptography; - using ConduitLLM.Admin.Interfaces; using ConduitLLM.Configuration; -using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.DTOs.VirtualKey; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Interfaces; -using ConduitLLM.Core.Events; using ConduitLLM.Core.Services; +using VirtualKeyUtilities = ConduitLLM.Configuration.Utilities.VirtualKeyUtilities; using MassTransit; using Microsoft.EntityFrameworkCore; @@ -17,335 +14,124 @@ namespace ConduitLLM.Admin.Services { /// - /// Service for managing virtual keys through the Admin API + /// Service for managing virtual keys through the Admin API. + /// Inherits shared CRUD operations from and adds + /// Admin-specific concerns: cache invalidation, media cleanup, discovery, validation, and usage. /// - public partial class AdminVirtualKeyService : EventPublishingServiceBase, IAdminVirtualKeyService + public partial class AdminVirtualKeyService : VirtualKeyServiceBase, IAdminVirtualKeyService { - private readonly IVirtualKeyRepository _virtualKeyRepository; - private readonly IVirtualKeySpendHistoryRepository _spendHistoryRepository; - private readonly IVirtualKeyGroupRepository _groupRepository; - private readonly IVirtualKeyCache? _cache; // Optional cache for invalidation - private readonly IMediaLifecycleService? _mediaLifecycleService; // Optional media lifecycle service + // Aliases for partial class files that reference the underscore-prefixed fields + private IVirtualKeyRepository _virtualKeyRepository => VirtualKeyRepository; + private IVirtualKeyGroupRepository _groupRepository => GroupRepository; + private IVirtualKeySpendHistoryRepository _spendHistoryRepository => SpendHistoryRepository; + + // Admin-specific dependencies + private readonly IVirtualKeyCache? _cache; + private readonly IMediaLifecycleService? _mediaLifecycleService; private readonly IModelProviderMappingRepository _modelProviderMappingRepository; private readonly IModelCapabilityService _modelCapabilityService; private readonly IDbContextFactory _dbContextFactory; private readonly ILogger _logger; - private const int KeyLengthBytes = 32; // Generate a 256-bit key /// /// Initializes a new instance of the AdminVirtualKeyService class /// /// The virtual key repository /// The spend history repository - /// Optional Redis cache for immediate invalidation (null if not configured) - /// Optional event publishing endpoint (null if MassTransit not configured) + /// The virtual key group repository /// The logger /// The model provider mapping repository /// The model capability service /// The database context factory + /// Optional Redis cache for immediate invalidation (null if not configured) + /// Optional event publishing endpoint (null if MassTransit not configured) /// Optional media lifecycle service for cleaning up associated media files (null if not configured) - /// The virtual key group repository public AdminVirtualKeyService( IVirtualKeyRepository virtualKeyRepository, IVirtualKeySpendHistoryRepository spendHistoryRepository, IVirtualKeyGroupRepository groupRepository, - IVirtualKeyCache? cache, - IPublishEndpoint? publishEndpoint, ILogger logger, IModelProviderMappingRepository modelProviderMappingRepository, IModelCapabilityService modelCapabilityService, IDbContextFactory dbContextFactory, + IVirtualKeyCache? cache = null, + IPublishEndpoint? publishEndpoint = null, IMediaLifecycleService? mediaLifecycleService = null) - : base(publishEndpoint, logger) + : base(virtualKeyRepository, groupRepository, spendHistoryRepository, publishEndpoint, logger) { - _virtualKeyRepository = virtualKeyRepository ?? throw new ArgumentNullException(nameof(virtualKeyRepository)); - _spendHistoryRepository = spendHistoryRepository ?? throw new ArgumentNullException(nameof(spendHistoryRepository)); - _groupRepository = groupRepository ?? throw new ArgumentNullException(nameof(groupRepository)); - _cache = cache; // Optional - can be null if Redis not configured - _mediaLifecycleService = mediaLifecycleService; // Optional - can be null if media lifecycle management not configured + _cache = cache; + _mediaLifecycleService = mediaLifecycleService; _modelProviderMappingRepository = modelProviderMappingRepository ?? throw new ArgumentNullException(nameof(modelProviderMappingRepository)); _modelCapabilityService = modelCapabilityService ?? throw new ArgumentNullException(nameof(modelCapabilityService)); _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Log event publishing configuration status - LogEventPublishingConfiguration(nameof(AdminVirtualKeyService)); } - /// - public async Task GenerateVirtualKeyAsync(CreateVirtualKeyRequestDto request) - { - _logger.LogInformation("Generating new virtual key with name: {KeyName}", (LoggingSanitizer.S(request.KeyName ?? ""))); - - // Generate a secure random key - var keyBytes = new byte[KeyLengthBytes]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(keyBytes); - var apiKey = Convert.ToBase64String(keyBytes); - - // Add the standard prefix - apiKey = VirtualKeyConstants.KeyPrefix + apiKey; - - // Hash the key for storage - var keyHash = ComputeSha256Hash(apiKey); + #region Virtual Hook Overrides - // Verify the group exists - var existingGroup = await _groupRepository.GetByIdAsync(request.VirtualKeyGroupId); - if (existingGroup == null) - { - throw new InvalidOperationException($"Virtual key group {request.VirtualKeyGroupId} not found. Ensure the group exists before creating keys."); - } - - // Warn if the group has zero balance - if (existingGroup.Balance <= 0) + /// + /// Cleans up associated media files before a virtual key is deleted. + /// + protected override async Task OnBeforeVirtualKeyDeleteAsync(int keyId) + { + if (_mediaLifecycleService != null) { - _logger.LogWarning( - "Virtual key group {GroupId} has zero balance. Keys in this group cannot make API calls until funded.", - request.VirtualKeyGroupId); + try + { + var deletedMediaCount = await _mediaLifecycleService.DeleteMediaForVirtualKeyAsync(keyId); + if (deletedMediaCount > 0) + { + Logger.LogInformation("Deleted {Count} media files for virtual key {KeyId}", deletedMediaCount, keyId); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete media files for virtual key {KeyId}, but continuing with key deletion", keyId); + } } - - var groupId = request.VirtualKeyGroupId; - - // Create the virtual key entity - var virtualKey = new VirtualKey - { - KeyName = request.KeyName ?? string.Empty, - KeyHash = keyHash, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - ExpiresAt = request.ExpiresAt, - VirtualKeyGroupId = groupId, - IsEnabled = true, - AllowedModels = request.AllowedModels, - Metadata = request.Metadata, - RateLimitRpm = request.RateLimitRpm, - RateLimitRpd = request.RateLimitRpd - }; - - // Save to database - var id = await _virtualKeyRepository.CreateAsync(virtualKey); - - // The entity is saved with an ID, now retrieve it to get all properties - virtualKey = await _virtualKeyRepository.GetByIdAsync(id); - if (virtualKey == null) + else { - throw new InvalidOperationException($"Failed to retrieve newly created virtual key with ID {id}"); + Logger.LogWarning("Media lifecycle service not available, media files for virtual key {KeyId} will become orphaned", keyId); } - - // No longer need to initialize spend history - budget is tracked at group level - - // Publish VirtualKeyCreated event for cache synchronization - // This is critical for the Gateway API to recognize the new key - await PublishEventAsync( - new VirtualKeyCreated - { - KeyId = virtualKey.Id, - KeyHash = virtualKey.KeyHash, - KeyName = virtualKey.KeyName, - CreatedAt = virtualKey.CreatedAt, - IsEnabled = virtualKey.IsEnabled, - AllowedModels = virtualKey.AllowedModels, - VirtualKeyGroupId = virtualKey.VirtualKeyGroupId, - CorrelationId = Guid.NewGuid().ToString() - }, - $"create virtual key {virtualKey.Id}", - new { KeyName = virtualKey.KeyName }); - - // Map to response DTO - var keyDto = MapToDto(virtualKey); - - // Return response with the generated key - return new CreateVirtualKeyResponseDto - { - VirtualKey = apiKey, - KeyInfo = keyDto - }; } - /// - public async Task GetVirtualKeyInfoAsync(int id) + /// + /// Invalidates the cache entry for an updated virtual key. + /// + protected override async Task OnVirtualKeyUpdatedAsync(VirtualKey key, string[] changedProperties) { - _logger.LogInformation("Getting virtual key info for ID: {KeyId}", id); - - var key = await _virtualKeyRepository.GetByIdAsync(id); - if (key == null) - { - _logger.LogWarning("Virtual key with ID {KeyId} not found", id); - return null; - } - - return MapToDto(key); + if (_cache != null) + await _cache.InvalidateVirtualKeyAsync(key.KeyHash); } - /// - public async Task> ListVirtualKeysAsync(int? virtualKeyGroupId = null) + /// + /// Invalidates the cache entry for a deleted virtual key. + /// + protected override async Task OnVirtualKeyDeletedAsync(VirtualKey key) { - if (virtualKeyGroupId.HasValue) - { - _logger.LogInformation("Listing virtual keys for group {GroupId}", virtualKeyGroupId.Value); - var keysByGroup = await _virtualKeyRepository.GetByVirtualKeyGroupIdAsync(virtualKeyGroupId.Value); - return keysByGroup.ConvertAll(MapToDto); - } - else - { - _logger.LogInformation("Listing all virtual keys"); - var keys = await _virtualKeyRepository.GetAllAsync(); - return keys.ConvertAll(MapToDto); - } + if (_cache != null) + await _cache.InvalidateVirtualKeyAsync(key.KeyHash); } - /// - public async Task UpdateVirtualKeyAsync(int id, UpdateVirtualKeyRequestDto request) - { - _logger.LogInformation("Updating virtual key with ID: {KeyId}", id); - - var key = await _virtualKeyRepository.GetByIdAsync(id); - if (key == null) - { - _logger.LogWarning("Virtual key with ID {KeyId} not found", id); - return false; - } - - // Track changed properties for event publishing - var changedProperties = new List(); - - // Update properties and track changes - if (request.KeyName != null && key.KeyName != request.KeyName) - { - key.KeyName = request.KeyName; - changedProperties.Add(nameof(key.KeyName)); - } - - if (request.AllowedModels != null && key.AllowedModels != request.AllowedModels) - { - key.AllowedModels = request.AllowedModels; - changedProperties.Add(nameof(key.AllowedModels)); - } - - if (request.VirtualKeyGroupId.HasValue && key.VirtualKeyGroupId != request.VirtualKeyGroupId.Value) - { - // Verify the new group exists - var newGroup = await _groupRepository.GetByIdAsync(request.VirtualKeyGroupId.Value); - if (newGroup == null) - { - throw new InvalidOperationException($"Virtual key group with ID {request.VirtualKeyGroupId.Value} not found"); - } - key.VirtualKeyGroupId = request.VirtualKeyGroupId.Value; - changedProperties.Add(nameof(key.VirtualKeyGroupId)); - } - - if (request.IsEnabled.HasValue && key.IsEnabled != request.IsEnabled.Value) - { - key.IsEnabled = request.IsEnabled.Value; - changedProperties.Add(nameof(key.IsEnabled)); - } - - if (request.ExpiresAt.HasValue && key.ExpiresAt != request.ExpiresAt) - { - key.ExpiresAt = request.ExpiresAt; - changedProperties.Add(nameof(key.ExpiresAt)); - } - - if (request.Metadata != null && key.Metadata != request.Metadata) - { - key.Metadata = request.Metadata; - changedProperties.Add(nameof(key.Metadata)); - } - - if (request.RateLimitRpm.HasValue && key.RateLimitRpm != request.RateLimitRpm) - { - key.RateLimitRpm = request.RateLimitRpm; - changedProperties.Add(nameof(key.RateLimitRpm)); - } - - if (request.RateLimitRpd.HasValue && key.RateLimitRpd != request.RateLimitRpd) - { - key.RateLimitRpd = request.RateLimitRpd; - changedProperties.Add(nameof(key.RateLimitRpd)); - } - - // Only proceed if there are actual changes - if (changedProperties.Count() == 0) - { - _logger.LogDebug("No changes detected for virtual key {KeyId} - skipping update", id); - return true; - } - - key.UpdatedAt = DateTime.UtcNow; - - // Save changes - var result = await _virtualKeyRepository.UpdateAsync(key); - - if (result) - { - // Publish VirtualKeyUpdated event for cache invalidation and cross-service coordination - await PublishEventAsync( - new VirtualKeyUpdated - { - KeyId = key.Id, - KeyHash = key.KeyHash, - ChangedProperties = changedProperties.ToArray(), - CorrelationId = Guid.NewGuid().ToString() - }, - $"update virtual key {id}", - new { ChangedProperties = string.Join(", ", changedProperties) }); - } - - return result; - } + #endregion /// - public async Task DeleteVirtualKeyAsync(int id) + public async Task> ListVirtualKeysAsync(int? virtualKeyGroupId = null) { - _logger.LogInformation("Deleting virtual key with ID: {KeyId}", id); - - var key = await _virtualKeyRepository.GetByIdAsync(id); - if (key == null) + if (virtualKeyGroupId.HasValue) { - _logger.LogWarning("Virtual key with ID {KeyId} not found", id); - return false; - } - - // Cleanup associated media files if media lifecycle service is available - if (_mediaLifecycleService != null) - { - try - { - var deletedMediaCount = await _mediaLifecycleService.DeleteMediaForVirtualKeyAsync(id); - if (deletedMediaCount > 0) - { - _logger.LogInformation("Deleted {Count} media files for virtual key {KeyId}", deletedMediaCount, id); - } - } - catch (Exception ex) - { - // Log the error but don't fail the virtual key deletion - _logger.LogError(ex, "Failed to delete media files for virtual key {KeyId}, but continuing with key deletion", id); - } + _logger.LogDebug("Listing virtual keys for group {GroupId}", virtualKeyGroupId.Value); + var keysByGroup = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + VirtualKeyRepository.GetByVirtualKeyGroupIdPaginatedAsync, virtualKeyGroupId.Value); + return keysByGroup.ConvertAll(VirtualKeyUtilities.MapToDto); } else { - _logger.LogWarning("Media lifecycle service not available, media files for virtual key {KeyId} will become orphaned", id); + _logger.LogDebug("Listing all virtual keys"); + var keys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + VirtualKeyRepository.GetPaginatedAsync); + return keys.ConvertAll(VirtualKeyUtilities.MapToDto); } - - var result = await _virtualKeyRepository.DeleteAsync(id); - - if (result) - { - // Publish VirtualKeyDeleted event for cache invalidation and cleanup - await PublishEventAsync( - new VirtualKeyDeleted - { - KeyId = key.Id, - KeyHash = key.KeyHash, - KeyName = key.KeyName, - CorrelationId = Guid.NewGuid().ToString() - }, - $"delete virtual key {key.Id}", - new { KeyName = key.KeyName }); - } - - return result; } diff --git a/Services/ConduitLLM.Admin/Services/AnalyticsMetricsService.cs b/Services/ConduitLLM.Admin/Services/AnalyticsMetricsService.cs index 05d2074e1..34274e093 100644 --- a/Services/ConduitLLM.Admin/Services/AnalyticsMetricsService.cs +++ b/Services/ConduitLLM.Admin/Services/AnalyticsMetricsService.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Admin.Metrics; namespace ConduitLLM.Admin.Services { @@ -33,7 +34,8 @@ public void RecordCacheHit(string cacheKey) { var key = NormalizeCacheKey(cacheKey); _cacheHits.AddOrUpdate(key, 1, (k, v) => v + 1); - + AdminCacheMetrics.RecordHit(key); + // Log every 100 hits for monitoring if (_cacheHits[key] % 100 == 0) { @@ -45,7 +47,14 @@ public void RecordCacheHit(string cacheKey) public void RecordCacheMiss(string cacheKey) { var key = NormalizeCacheKey(cacheKey); - _cacheMisses.AddOrUpdate(key, 1, (k, v) => v + 1); + var newCount = _cacheMisses.AddOrUpdate(key, 1, (k, v) => v + 1); + AdminCacheMetrics.RecordMiss(key); + + // Log every 100 misses for monitoring + if (newCount % 100 == 0) + { + _logger.LogDebug("Cache key {CacheKey} has {MissCount} misses", key, newCount); + } } /// @@ -89,6 +98,13 @@ public void RecordFetchDuration(string dataSource, double durationMs) } return list; }); + + // Log slow fetches + if (durationMs > 2000) + { + _logger.LogWarning("Slow data fetch from {DataSource} took {Duration}ms", + dataSource, durationMs); + } } /// @@ -108,7 +124,8 @@ public void RecordCacheMemoryUsage(long sizeBytes) public void RecordCacheInvalidation(string reason, int keysInvalidated) { Interlocked.Increment(ref _totalCacheInvalidations); - _logger.LogInformation("Cache invalidated: {Reason}, {KeyCount} keys cleared", + AdminCacheMetrics.RecordInvalidation("analytics", reason); + _logger.LogInformation("Cache invalidated: {Reason}, {KeyCount} keys cleared", reason, keysInvalidated); } diff --git a/Services/ConduitLLM.Admin/Services/AnalyticsService.CombinedAnalytics.cs b/Services/ConduitLLM.Admin/Services/AnalyticsService.CombinedAnalytics.cs index 075cb351d..eb9512d1b 100644 --- a/Services/ConduitLLM.Admin/Services/AnalyticsService.CombinedAnalytics.cs +++ b/Services/ConduitLLM.Admin/Services/AnalyticsService.CombinedAnalytics.cs @@ -1,7 +1,9 @@ using System.Diagnostics; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Extensions; using Microsoft.Extensions.Caching.Memory; @@ -21,98 +23,98 @@ public async Task GetAnalyticsSummaryAsync( DateTime? endDate = null) { var stopwatch = Stopwatch.StartNew(); - var cacheKey = $"{CachePrefixSummary}full:{timeframe}:{startDate?.Ticks}:{endDate?.Ticks}"; + var cacheKey = $"{CacheKeys.Analytics.SummaryPrefix}full:{timeframe}:{startDate?.Ticks}:{endDate?.Ticks}"; var cacheHit = false; - + var result = await _cache.GetOrCreateAsync(cacheKey, async entry => { _metrics?.RecordCacheMiss(cacheKey); entry.AbsoluteExpirationRelativeToNow = MediumCacheDuration; - - _logger.LogInformation("Getting comprehensive analytics summary"); + + _logger.LogDebug("Getting comprehensive analytics summary"); timeframe = NormalizeTimeframe(timeframe); startDate ??= DateTime.UtcNow.AddDays(-30); endDate ??= DateTime.UtcNow; + // Fetch all aggregations from database in parallel โ€” no full log loading var fetchStopwatch = Stopwatch.StartNew(); - var logs = await _requestLogRepository.GetByDateRangeAsync(startDate.Value, endDate.Value); - _metrics?.RecordFetchDuration("RequestLogRepository.GetByDateRangeAsync", fetchStopwatch.ElapsedMilliseconds); - + var summaryTask = _requestLogRepository.GetSummaryAsync(startDate.Value, endDate.Value); + var modelTask = _requestLogRepository.GetAggregatedByModelAsync(startDate.Value, endDate.Value); + var virtualKeyTask = _requestLogRepository.GetAggregatedByVirtualKeyAsync(startDate.Value, endDate.Value); + var dailyStatsTask = _requestLogRepository.GetDailyStatisticsAsync(startDate.Value, endDate.Value); + var comparisonTask = CalculatePreviousPeriodComparison(startDate.Value, endDate.Value); + + await Task.WhenAll(summaryTask, modelTask, virtualKeyTask, dailyStatsTask, comparisonTask); + _metrics?.RecordFetchDuration("RequestLogRepository.AggregateQueries", fetchStopwatch.ElapsedMilliseconds); + + var summary = await summaryTask; + var modelAggregations = await modelTask; + var virtualKeyAggregations = await virtualKeyTask; + + // Get virtual key names for the top keys fetchStopwatch.Restart(); - var virtualKeys = await _virtualKeyRepository.GetAllAsync(); - _metrics?.RecordFetchDuration("VirtualKeyRepository.GetAllAsync", fetchStopwatch.ElapsedMilliseconds); - var keyMap = virtualKeys.ToDictionary(k => k.Id, k => k.KeyName); - - // Calculate metrics - var successfulRequests = logs.Count(l => l.StatusCode >= 200 && l.StatusCode < 300); - var totalRequests = logs.Count; - var successRate = totalRequests > 0 ? (successfulRequests * 100.0 / totalRequests) : 0; - - // Get top models - var topModels = logs - .GroupBy(l => l.ModelName) - .Select(g => new ModelUsageSummary - { - ModelName = g.Key, - RequestCount = g.Count(), - TotalCost = g.Sum(l => l.Cost), - InputTokens = g.Sum(l => (long)l.InputTokens), - OutputTokens = g.Sum(l => (long)l.OutputTokens), - AverageResponseTime = g.Average(l => l.ResponseTimeMs), - ErrorRate = g.Count(l => l.StatusCode >= 400) * 100.0 / g.Count() - }) - .OrderByDescending(m => m.TotalCost) - .Take(10) - .ToList(); - - // Get top virtual keys - var topVirtualKeys = logs - .GroupBy(l => l.VirtualKeyId) - .Select(g => new VirtualKeyUsageSummary - { - VirtualKeyId = g.Key, - KeyName = keyMap.GetValueOrDefault(g.Key, $"Key #{g.Key}"), - RequestCount = g.Count(), - TotalCost = g.Sum(l => l.Cost), - LastUsed = g.Max(l => l.Timestamp), - ModelsUsed = g.Select(l => l.ModelName).Distinct().ToList() - }) - .OrderByDescending(v => v.TotalCost) - .Take(10) - .ToList(); - - // Calculate daily statistics - var dailyStats = CalculateDailyStatistics(logs, timeframe); - - // Get comparison with previous period - var comparison = await CalculatePreviousPeriodComparison(startDate.Value, endDate.Value); + var virtualKeyIds = virtualKeyAggregations.Take(10).Select(v => v.VirtualKeyId).ToList(); + var keyMap = virtualKeyIds.Count != 0 + ? await _virtualKeyRepository.GetKeyNamesByIdsAsync(virtualKeyIds) + : new Dictionary(); + _metrics?.RecordFetchDuration("VirtualKeyRepository.GetKeyNamesByIdsAsync", fetchStopwatch.ElapsedMilliseconds); + + var successRate = summary.TotalRequests > 0 + ? (summary.SuccessCount * 100.0 / summary.TotalRequests) + : 0; + + // Convert model aggregations to top models summary + var topModels = modelAggregations.Take(10).Select(m => new ModelUsageSummary + { + ModelName = m.ModelName, + RequestCount = m.RequestCount, + TotalCost = m.TotalCost, + InputTokens = m.InputTokens, + OutputTokens = m.OutputTokens, + AverageResponseTime = 0, // Not available from model aggregation (would need additional query) + ErrorRate = 0 // Not available from model aggregation + }).ToList(); + + // Convert virtual key aggregations to top keys summary + var topVirtualKeys = virtualKeyAggregations.Take(10).Select(v => new VirtualKeyUsageSummary + { + VirtualKeyId = v.VirtualKeyId, + KeyName = keyMap.GetValueOrDefault(v.VirtualKeyId, $"Key #{v.VirtualKeyId}"), + RequestCount = v.RequestCount, + TotalCost = v.TotalCost, + LastUsed = v.LastUsed, + ModelsUsed = new List() // Not available from aggregation + }).ToList(); + + // Aggregate daily stats to requested timeframe + var dailyStats = AggregateStatisticsByTimeframe(await dailyStatsTask, timeframe); return new AnalyticsSummaryDto { - TotalRequests = totalRequests, - TotalCost = logs.Sum(l => l.Cost), - TotalInputTokens = logs.Sum(l => (long)l.InputTokens), - TotalOutputTokens = logs.Sum(l => (long)l.OutputTokens), - AverageResponseTime = logs.Any() ? logs.Average(l => l.ResponseTimeMs) : 0, + TotalRequests = summary.TotalRequests, + TotalCost = summary.TotalCost, + TotalInputTokens = summary.TotalInputTokens, + TotalOutputTokens = summary.TotalOutputTokens, + AverageResponseTime = summary.AverageResponseTimeMs, SuccessRate = successRate, - UniqueVirtualKeys = logs.Select(l => l.VirtualKeyId).Distinct().Count(), - UniqueModels = logs.Select(l => l.ModelName).Distinct().Count(), + UniqueVirtualKeys = virtualKeyAggregations.Count, + UniqueModels = modelAggregations.Count, TopModels = topModels, TopVirtualKeys = topVirtualKeys, DailyStats = dailyStats, - Comparison = comparison + Comparison = await comparisonTask }; }); - + if (!cacheHit && result != null) { cacheHit = true; _metrics?.RecordCacheHit(cacheKey); } - + _metrics?.RecordOperationDuration("GetAnalyticsSummaryAsync", stopwatch.ElapsedMilliseconds); - + return result ?? new AnalyticsSummaryDto { TotalRequests = 0, @@ -136,35 +138,37 @@ public async Task GetVirtualKeyUsageAsync( DateTime? startDate = null, DateTime? endDate = null) { - _logger.LogInformation("Getting usage statistics for virtual key {VirtualKeyId}", virtualKeyId); + _logger.LogDebug("Getting usage statistics for virtual key {VirtualKeyId}", virtualKeyId); startDate ??= DateTime.UtcNow.AddDays(-30); endDate ??= DateTime.UtcNow; - // Get all logs and filter by virtual key - var allLogs = await _requestLogRepository.GetByDateRangeAsync(startDate.Value, endDate.Value); - var logs = allLogs.Where(l => l.VirtualKeyId == virtualKeyId).ToList(); + // Fetch summary and model breakdown for this specific key via database-level aggregation + var summaryTask = _requestLogRepository.GetSummaryForVirtualKeyAsync(virtualKeyId, startDate.Value, endDate.Value); + var modelTask = _requestLogRepository.GetAggregatedByModelForVirtualKeyAsync(virtualKeyId, startDate.Value, endDate.Value); + await Task.WhenAll(summaryTask, modelTask); + + var summary = await summaryTask; + var modelAggregations = await modelTask; var result = new UsageStatisticsDto { - TotalRequests = logs.Count(), - TotalCost = logs.Sum(l => l.Cost), - TotalInputTokens = logs.Sum(l => l.InputTokens), - TotalOutputTokens = logs.Sum(l => l.OutputTokens), - AverageResponseTimeMs = logs.Any() ? logs.Average(l => l.ResponseTimeMs) : 0, + TotalRequests = summary.TotalRequests, + TotalCost = summary.TotalCost, + TotalInputTokens = (int)Math.Min(summary.TotalInputTokens, int.MaxValue), + TotalOutputTokens = (int)Math.Min(summary.TotalOutputTokens, int.MaxValue), + AverageResponseTimeMs = summary.AverageResponseTimeMs, ModelUsage = new Dictionary() }; - // Group by model - var modelGroups = logs.GroupBy(l => l.ModelName); - foreach (var group in modelGroups) + foreach (var model in modelAggregations) { - result.ModelUsage[group.Key] = new ModelUsage + result.ModelUsage[model.ModelName] = new ModelUsage { - RequestCount = group.Count(), - Cost = group.Sum(l => l.Cost), - InputTokens = group.Sum(l => l.InputTokens), - OutputTokens = group.Sum(l => l.OutputTokens) + RequestCount = model.RequestCount, + Cost = model.TotalCost, + InputTokens = (int)Math.Min(model.InputTokens, int.MaxValue), + OutputTokens = (int)Math.Min(model.OutputTokens, int.MaxValue) }; } @@ -179,28 +183,40 @@ public async Task ExportAnalyticsAsync( string? model = null, int? virtualKeyId = null) { - _logger.LogInformation("Exporting analytics in {Format} format", format); + _logger.LogInformation("Exporting analytics in {Format} format, date range {StartDate} to {EndDate}", + format, startDate?.ToString("yyyy-MM-dd") ?? "default", endDate?.ToString("yyyy-MM-dd") ?? "default"); - startDate ??= DateTime.UtcNow.AddDays(-30); - endDate ??= DateTime.UtcNow; + try + { + startDate ??= DateTime.UtcNow.AddDays(-30); + endDate ??= DateTime.UtcNow; - var logs = await _requestLogRepository.GetByDateRangeAsync(startDate.Value, endDate.Value); + // Export requires full entity data, but the repository pushes the model and + // virtual-key filters into SQL so we don't materialize rows we'd just discard. + var logs = await _requestLogRepository.GetByDateRangeFilteredAsync( + startDate.Value, endDate.Value, model, virtualKeyId); - // Apply filters - if (!string.IsNullOrEmpty(model)) - logs = logs.Where(l => l.ModelName.Contains(model, StringComparison.OrdinalIgnoreCase)).ToList(); - - if (virtualKeyId.HasValue) - logs = logs.Where(l => l.VirtualKeyId == virtualKeyId.Value).ToList(); + _logger.LogInformation("Analytics export completed: {RecordCount} records in {Format} format", + logs.Count, format); - return format.ToLower() switch + return format.ToLower() switch + { + "csv" => ExportToCsv(logs), + "json" => ExportToJson(logs), + _ => throw new ArgumentException($"Unsupported export format: {format}") + }; + } + catch (ArgumentException) { - "csv" => ExportToCsv(logs), - "json" => ExportToJson(logs), - _ => throw new ArgumentException($"Unsupported export format: {format}") - }; + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error exporting analytics in {Format} format", format); + throw; + } } #endregion } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Services/AnalyticsService.CostAnalytics.cs b/Services/ConduitLLM.Admin/Services/AnalyticsService.CostAnalytics.cs index fdc95e3cf..996c1a120 100644 --- a/Services/ConduitLLM.Admin/Services/AnalyticsService.CostAnalytics.cs +++ b/Services/ConduitLLM.Admin/Services/AnalyticsService.CostAnalytics.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.DTOs.Costs; using Microsoft.Extensions.Caching.Memory; @@ -20,30 +22,38 @@ public async Task GetCostSummaryAsync( DateTime? endDate = null) { var stopwatch = Stopwatch.StartNew(); - var cacheKey = $"{CachePrefixSummary}cost:{timeframe}:{startDate?.Ticks}:{endDate?.Ticks}"; + var cacheKey = $"{CacheKeys.Analytics.SummaryPrefix}cost:{timeframe}:{startDate?.Ticks}:{endDate?.Ticks}"; var cacheHit = false; - + var result = await _cache.GetOrCreateAsync(cacheKey, async entry => { _metrics?.RecordCacheMiss(cacheKey); entry.AbsoluteExpirationRelativeToNow = ShortCacheDuration; - - _logger.LogInformation("Getting cost summary with timeframe: {Timeframe}", timeframe); + + _logger.LogDebug("Getting cost summary with timeframe: {Timeframe}", timeframe); // Normalize parameters timeframe = NormalizeTimeframe(timeframe); startDate = startDate.HasValue ? DateTime.SpecifyKind(startDate.Value, DateTimeKind.Utc) : DateTime.UtcNow.AddDays(-30); endDate = endDate.HasValue ? DateTime.SpecifyKind(endDate.Value, DateTimeKind.Utc) : DateTime.UtcNow; + // Fetch aggregations from database in parallel โ€” no full log loading var fetchStopwatch = Stopwatch.StartNew(); - var logs = await _requestLogRepository.GetByDateRangeAsync(startDate.Value, endDate.Value); - _metrics?.RecordFetchDuration("RequestLogRepository.GetByDateRangeAsync", fetchStopwatch.ElapsedMilliseconds); + var modelTask = _requestLogRepository.GetAggregatedByModelAsync(startDate.Value, endDate.Value); + var virtualKeyTask = _requestLogRepository.GetAggregatedByVirtualKeyAsync(startDate.Value, endDate.Value); + var dailyCostsTask = _requestLogRepository.GetCostsByDateAsync(startDate.Value, endDate.Value); + var last24hTask = _requestLogRepository.GetSummaryAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow); + var last7dTask = _requestLogRepository.GetSummaryAsync(DateTime.UtcNow.AddDays(-7), DateTime.UtcNow); + + await Task.WhenAll(modelTask, virtualKeyTask, dailyCostsTask, last24hTask, last7dTask); + _metrics?.RecordFetchDuration("RequestLogRepository.AggregateQueries", fetchStopwatch.ElapsedMilliseconds); + + var modelBreakdown = await modelTask; + var virtualKeyBreakdown = await virtualKeyTask; + var dailyCosts = await dailyCostsTask; + var providerBreakdown = CalculateProviderBreakdownFromModels(modelBreakdown); - // Calculate aggregations - var dailyCosts = CalculateDailyCosts(logs); - var modelBreakdown = CalculateModelBreakdown(logs); - var providerBreakdown = CalculateProviderBreakdown(logs); - var virtualKeyBreakdown = CalculateVirtualKeyBreakdown(logs); + var totalCost = dailyCosts.Sum(d => d.TotalCost); // Aggregate by timeframe var aggregatedCosts = AggregateByTimeframe(dailyCosts, timeframe); @@ -54,7 +64,7 @@ public async Task GetCostSummaryAsync( { Name = m.ModelName, Cost = m.TotalCost, - Percentage = logs.Any() ? (m.TotalCost / logs.Sum(l => l.Cost) * 100) : 0, + Percentage = totalCost > 0 ? (m.TotalCost / totalCost * 100) : 0, RequestCount = m.RequestCount }) ]; @@ -64,17 +74,17 @@ public async Task GetCostSummaryAsync( { Name = p.ProviderName, Cost = p.TotalCost, - Percentage = logs.Any() ? (p.TotalCost / logs.Sum(l => l.Cost) * 100) : 0, + Percentage = totalCost > 0 ? (p.TotalCost / totalCost * 100) : 0, RequestCount = p.RequestCount }) ]; List topVirtualKeysBySpend = [ - ..virtualKeyBreakdown.Take(10).Select(v => new DetailedCostDataDto + ..ToVirtualKeyCostDetails(virtualKeyBreakdown).Take(10).Select(v => new DetailedCostDataDto { Name = v.KeyName, Cost = v.TotalCost, - Percentage = logs.Any() ? (v.TotalCost / logs.Sum(l => l.Cost) * 100) : 0, + Percentage = totalCost > 0 ? (v.TotalCost / totalCost * 100) : 0, RequestCount = v.RequestCount }) ]; @@ -84,24 +94,24 @@ public async Task GetCostSummaryAsync( TimeFrame = timeframe, StartDate = startDate.Value, EndDate = endDate.Value, - TotalCost = logs.Sum(l => l.Cost), - Last24HoursCost = CalculateLast24HoursCost(logs), - Last7DaysCost = CalculateLast7DaysCost(logs), - Last30DaysCost = CalculateLast30DaysCost(logs), + TotalCost = totalCost, + Last24HoursCost = (await last24hTask).TotalCost, + Last7DaysCost = (await last7dTask).TotalCost, + Last30DaysCost = totalCost, // Date range already defaults to 30 days TopModelsBySpend = topModelsBySpend, TopProvidersBySpend = topProvidersBySpend, TopVirtualKeysBySpend = topVirtualKeysBySpend }; }); - + if (!cacheHit && result != null) { cacheHit = true; _metrics?.RecordCacheHit(cacheKey); } - + _metrics?.RecordOperationDuration("GetCostSummaryAsync", stopwatch.ElapsedMilliseconds); - + return result ?? new CostDashboardDto { TimeFrame = timeframe, @@ -124,27 +134,29 @@ public async Task GetCostTrendsAsync( DateTime? endDate = null) { var stopwatch = Stopwatch.StartNew(); - var cacheKey = $"{CachePrefixCostTrend}{period}:{startDate?.Ticks}:{endDate?.Ticks}"; + var cacheKey = $"{CacheKeys.Analytics.CostTrendPrefix}{period}:{startDate?.Ticks}:{endDate?.Ticks}"; var cacheHit = false; - + var result = await _cache.GetOrCreateAsync(cacheKey, async entry => { _metrics?.RecordCacheMiss(cacheKey); entry.AbsoluteExpirationRelativeToNow = MediumCacheDuration; - - _logger.LogInformation("Getting cost trends with period: {Period}", period); + + _logger.LogDebug("Getting cost trends with period: {Period}", period); period = NormalizeTimeframe(period); startDate = startDate.HasValue ? DateTime.SpecifyKind(startDate.Value, DateTimeKind.Utc) : DateTime.UtcNow.AddDays(-30); endDate = endDate.HasValue ? DateTime.SpecifyKind(endDate.Value, DateTimeKind.Utc) : DateTime.UtcNow; + // Fetch daily cost aggregations from database and comparison in parallel var fetchStopwatch = Stopwatch.StartNew(); - var logs = await _requestLogRepository.GetByDateRangeAsync(startDate.Value, endDate.Value); - _metrics?.RecordFetchDuration("RequestLogRepository.GetByDateRangeAsync", fetchStopwatch.ElapsedMilliseconds); + var dailyCostsTask = _requestLogRepository.GetCostsByDateAsync(startDate.Value, endDate.Value); + var comparisonTask = CalculatePreviousPeriodComparison(startDate.Value, endDate.Value); + await Task.WhenAll(dailyCostsTask, comparisonTask); + _metrics?.RecordFetchDuration("RequestLogRepository.GetCostsByDateAsync", fetchStopwatch.ElapsedMilliseconds); - // Calculate trends - var trendData = CalculateCostTrends(logs, period); - var previousPeriodComparison = await CalculatePreviousPeriodComparison(startDate.Value, endDate.Value); + // Calculate trends from daily aggregations (~365 rows max) + var trendData = CalculateCostTrendsFromDaily(await dailyCostsTask, period); // Convert to CostTrendDataDto format var trendDataDto = trendData.Select(t => new CostTrendDataDto @@ -162,15 +174,15 @@ public async Task GetCostTrendsAsync( Data = trendDataDto }; }); - + if (!cacheHit && result != null) { cacheHit = true; _metrics?.RecordCacheHit(cacheKey); } - + _metrics?.RecordOperationDuration("GetCostTrendsAsync", stopwatch.ElapsedMilliseconds); - + return result ?? new CostTrendDto { Period = period, @@ -186,21 +198,23 @@ public async Task GetModelCostsAsync( DateTime? endDate = null, int topN = 10) { - _logger.LogInformation("Getting model costs breakdown"); + _logger.LogDebug("Getting model costs breakdown"); startDate = startDate.HasValue ? DateTime.SpecifyKind(startDate.Value, DateTimeKind.Utc) : DateTime.UtcNow.AddDays(-30); endDate = endDate.HasValue ? DateTime.SpecifyKind(endDate.Value, DateTimeKind.Utc) : DateTime.UtcNow; - var logs = await _requestLogRepository.GetByDateRangeAsync(startDate.Value, endDate.Value); - var modelBreakdown = CalculateModelBreakdown(logs); + var modelAggregations = await _requestLogRepository.GetAggregatedByModelAsync(startDate.Value, endDate.Value); + var modelBreakdown = ToModelCostDetails(modelAggregations); + var totalCost = modelAggregations.Sum(m => m.TotalCost); + var totalRequests = modelAggregations.Sum(m => m.RequestCount); return new ModelCostBreakdownDto { StartDate = startDate.Value, EndDate = endDate.Value, Models = modelBreakdown.Take(topN).ToList(), - TotalCost = logs.Sum(l => l.Cost), - TotalRequests = logs.Count + TotalCost = totalCost, + TotalRequests = totalRequests }; } @@ -210,41 +224,46 @@ public async Task GetVirtualKeyCostsAsync( DateTime? endDate = null, int topN = 10) { - _logger.LogInformation("Getting virtual key costs breakdown"); + _logger.LogDebug("Getting virtual key costs breakdown"); startDate = startDate.HasValue ? DateTime.SpecifyKind(startDate.Value, DateTimeKind.Utc) : DateTime.UtcNow.AddDays(-30); endDate = endDate.HasValue ? DateTime.SpecifyKind(endDate.Value, DateTimeKind.Utc) : DateTime.UtcNow; - var logs = await _requestLogRepository.GetByDateRangeAsync(startDate.Value, endDate.Value); - var virtualKeys = await _virtualKeyRepository.GetAllAsync(); - var keyMap = virtualKeys.ToDictionary(k => k.Id, k => k.KeyName); + var keyAggregations = await _requestLogRepository.GetAggregatedByVirtualKeyAsync(startDate.Value, endDate.Value); + + // Get only the virtual key names we need using efficient lookup + var virtualKeyIds = keyAggregations.Select(k => k.VirtualKeyId).ToList(); + var keyMap = virtualKeyIds.Count != 0 + ? await _virtualKeyRepository.GetKeyNamesByIdsAsync(virtualKeyIds) + : new Dictionary(); - var breakdown = logs - .GroupBy(l => l.VirtualKeyId) - .Select(g => new VirtualKeyCostDetail + var breakdown = keyAggregations + .Select(v => new VirtualKeyCostDetail { - VirtualKeyId = g.Key, - KeyName = keyMap.GetValueOrDefault(g.Key, $"Key #{g.Key}"), - TotalCost = g.Sum(l => l.Cost), - RequestCount = g.Count(), - AverageCostPerRequest = g.Average(l => l.Cost), - LastUsed = g.Max(l => l.Timestamp), - UniqueModels = g.Select(l => l.ModelName).Distinct().Count() + VirtualKeyId = v.VirtualKeyId, + KeyName = keyMap.GetValueOrDefault(v.VirtualKeyId, $"Key #{v.VirtualKeyId}"), + TotalCost = v.TotalCost, + RequestCount = v.RequestCount, + AverageCostPerRequest = v.RequestCount > 0 ? v.TotalCost / v.RequestCount : 0, + LastUsed = v.LastUsed, + UniqueModels = v.UniqueModels }) - .OrderByDescending(v => v.TotalCost) .Take(topN) .ToList(); + var totalCost = keyAggregations.Sum(k => k.TotalCost); + var totalRequests = keyAggregations.Sum(k => k.RequestCount); + return new VirtualKeyCostBreakdownDto { StartDate = startDate.Value, EndDate = endDate.Value, VirtualKeys = breakdown, - TotalCost = logs.Sum(l => l.Cost), - TotalRequests = logs.Count + TotalCost = totalCost, + TotalRequests = totalRequests }; } #endregion } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Services/AnalyticsService.Helpers.cs b/Services/ConduitLLM.Admin/Services/AnalyticsService.Helpers.cs index c18a9b729..d2964721d 100644 --- a/Services/ConduitLLM.Admin/Services/AnalyticsService.Helpers.cs +++ b/Services/ConduitLLM.Admin/Services/AnalyticsService.Helpers.cs @@ -46,64 +46,67 @@ private static string NormalizeTimeframe(string timeframe) }; } - private static List<(DateTime Date, decimal Cost)> CalculateDailyCosts(IEnumerable logs) + /// + /// Converts model aggregations from DB to ModelCostDetail DTOs. + /// Provider breakdown is derived from model names (e.g., "openai/gpt-4" โ†’ "openai") + /// since it requires string parsing that can't be done at the database level. + /// + private static List ToModelCostDetails(List models) { - return logs - .GroupBy(l => l.Timestamp.Date) - .Select(g => (Date: g.Key, Cost: g.Sum(l => l.Cost))) - .OrderBy(d => d.Date) - .ToList(); - } - - private static List CalculateModelBreakdown(IEnumerable logs) - { - return logs - .GroupBy(l => l.ModelName) - .Select(g => new ModelCostDetail + return models + .Select(m => new ModelCostDetail { - ModelName = g.Key, - TotalCost = g.Sum(l => l.Cost), - RequestCount = g.Count(), - InputTokens = g.Sum(l => (long)l.InputTokens), - OutputTokens = g.Sum(l => (long)l.OutputTokens), - AverageCostPerRequest = g.Average(l => l.Cost), - CostPercentage = 0 // Will be calculated later + ModelName = m.ModelName, + TotalCost = m.TotalCost, + RequestCount = m.RequestCount, + InputTokens = m.InputTokens, + OutputTokens = m.OutputTokens, + AverageCostPerRequest = m.RequestCount > 0 ? m.TotalCost / m.RequestCount : 0, + CostPercentage = 0 // Calculated by caller if needed }) - .OrderByDescending(m => m.TotalCost) .ToList(); } - private static List CalculateProviderBreakdown(IEnumerable logs) + /// + /// Derives provider breakdown from model aggregations by extracting the provider + /// prefix from model names (e.g., "openai/gpt-4" โ†’ "openai"). + /// This is an in-memory operation on the small model aggregation set (~10-100 rows), + /// not on individual request log rows. + /// + private static List CalculateProviderBreakdownFromModels(List models) { - return logs - .GroupBy(l => ExtractProviderFromModel(l.ModelName)) + return models + .GroupBy(m => ExtractProviderFromModel(m.ModelName)) .Select(g => new ProviderCostDetail { ProviderName = g.Key, - TotalCost = g.Sum(l => l.Cost), - RequestCount = g.Count(), - AverageCostPerRequest = g.Average(l => l.Cost), - CostPercentage = 0 // Will be calculated later + TotalCost = g.Sum(m => m.TotalCost), + RequestCount = g.Sum(m => m.RequestCount), + AverageCostPerRequest = g.Sum(m => m.RequestCount) > 0 + ? g.Sum(m => m.TotalCost) / g.Sum(m => m.RequestCount) + : 0, + CostPercentage = 0 // Calculated by caller if needed }) .OrderByDescending(p => p.TotalCost) .ToList(); } - private static List CalculateVirtualKeyBreakdown(IEnumerable logs) + /// + /// Converts virtual key aggregations from DB to VirtualKeyCostDetail DTOs. + /// + private static List ToVirtualKeyCostDetails(List keys) { - return logs - .GroupBy(l => l.VirtualKeyId) - .Select(g => new VirtualKeyCostDetail + return keys + .Select(v => new VirtualKeyCostDetail { - VirtualKeyId = g.Key, - KeyName = $"Key #{g.Key}", // Will be enriched with actual name - TotalCost = g.Sum(l => l.Cost), - RequestCount = g.Count(), - AverageCostPerRequest = g.Average(l => l.Cost), - LastUsed = g.Max(l => l.Timestamp), - UniqueModels = g.Select(l => l.ModelName).Distinct().Count() + VirtualKeyId = v.VirtualKeyId, + KeyName = $"Key #{v.VirtualKeyId}", // Enriched by caller with actual name + TotalCost = v.TotalCost, + RequestCount = v.RequestCount, + AverageCostPerRequest = v.RequestCount > 0 ? v.TotalCost / v.RequestCount : 0, + LastUsed = v.LastUsed, + UniqueModels = v.UniqueModels }) - .OrderByDescending(v => v.TotalCost) .ToList(); } @@ -114,41 +117,125 @@ private static string ExtractProviderFromModel(string modelName) return parts.Length > 1 ? parts[0] : "unknown"; } - private static decimal CalculateLast24HoursCost(IEnumerable logs) - { - var cutoff = DateTime.UtcNow.AddDays(-1); - return logs.Where(l => l.Timestamp >= cutoff).Sum(l => l.Cost); - } - - private static decimal CalculateLast7DaysCost(IEnumerable logs) + /// + /// Aggregates daily statistics to weekly or monthly granularity. + /// Operates on the small daily aggregation set (~365 rows/year) from the database, + /// not on individual request log rows. + /// + private static List AggregateStatisticsByTimeframe( + List dailyStats, + string timeframe) { - var cutoff = DateTime.UtcNow.AddDays(-7); - return logs.Where(l => l.Timestamp >= cutoff).Sum(l => l.Cost); - } + if (timeframe == "daily") + { + return dailyStats + .Select(d => new DailyStatistics + { + Date = d.Date, + RequestCount = d.RequestCount, + Cost = d.Cost, + InputTokens = d.InputTokens, + OutputTokens = d.OutputTokens, + AverageResponseTime = d.AverageResponseTime, + ErrorCount = d.ErrorCount + }) + .ToList(); + } - private static decimal CalculateLast30DaysCost(IEnumerable logs) - { - var cutoff = DateTime.UtcNow.AddDays(-30); - return logs.Where(l => l.Timestamp >= cutoff).Sum(l => l.Cost); - } + var grouped = timeframe switch + { + "weekly" => dailyStats.GroupBy(d => GetStartOfWeek(d.Date)), + "monthly" => dailyStats.GroupBy(d => new DateTime(d.Date.Year, d.Date.Month, 1)), + _ => dailyStats.GroupBy(d => d.Date) + }; - private static decimal CalculateAverageDailyCost(List<(DateTime Date, decimal Cost)> dailyCosts) - { - return dailyCosts.Any() ? dailyCosts.Average(d => d.Cost) : 0; + return grouped + .Select(g => + { + var totalRequests = g.Sum(d => d.RequestCount); + return new DailyStatistics + { + Date = g.Key, + RequestCount = totalRequests, + Cost = g.Sum(d => d.Cost), + InputTokens = g.Sum(d => d.InputTokens), + OutputTokens = g.Sum(d => d.OutputTokens), + AverageResponseTime = totalRequests > 0 + ? g.Sum(d => d.AverageResponseTime * d.RequestCount) / totalRequests + : 0, + ErrorCount = g.Sum(d => d.ErrorCount) + }; + }) + .OrderBy(s => s.Date) + .ToList(); } + /// + /// Aggregates daily cost data to weekly or monthly granularity. + /// private static List<(DateTime Date, decimal Cost)> AggregateByTimeframe( - List<(DateTime Date, decimal Cost)> dailyCosts, + List dailyCosts, string timeframe) { + if (timeframe == "daily") + { + return dailyCosts.Select(d => (d.Date, d.TotalCost)).ToList(); + } + + var tuples = dailyCosts.Select(d => (d.Date, d.TotalCost)).ToList(); return timeframe switch { - "weekly" => AggregateByWeek(dailyCosts), - "monthly" => AggregateByMonth(dailyCosts), - _ => dailyCosts + "weekly" => AggregateByWeek(tuples), + "monthly" => AggregateByMonth(tuples), + _ => tuples }; } + /// + /// Calculates cost trend points from daily cost aggregations. + /// + private static List CalculateCostTrendsFromDaily( + List dailyCosts, + string period) + { + if (period == "daily") + { + return dailyCosts + .Select(d => new CostTrendPoint + { + Date = d.Date, + Cost = d.TotalCost, + RequestCount = d.RequestCount, + AverageRequestCost = d.RequestCount > 0 ? d.TotalCost / d.RequestCount : 0 + }) + .OrderBy(t => t.Date) + .ToList(); + } + + var grouped = period switch + { + "weekly" => dailyCosts.GroupBy(d => GetStartOfWeek(d.Date)), + "monthly" => dailyCosts.GroupBy(d => new DateTime(d.Date.Year, d.Date.Month, 1)), + _ => dailyCosts.GroupBy(d => d.Date) + }; + + return grouped + .Select(g => + { + var totalRequests = g.Sum(d => d.RequestCount); + var totalCost = g.Sum(d => d.TotalCost); + return new CostTrendPoint + { + Date = g.Key, + Cost = totalCost, + RequestCount = totalRequests, + AverageRequestCost = totalRequests > 0 ? totalCost / totalRequests : 0 + }; + }) + .OrderBy(t => t.Date) + .ToList(); + } + private static List<(DateTime Date, decimal Cost)> AggregateByWeek(List<(DateTime Date, decimal Cost)> dailyCosts) { return dailyCosts @@ -173,100 +260,59 @@ private static DateTime GetStartOfWeek(DateTime date) return date.AddDays(-1 * diff).Date; } - private List CalculateCostTrends(IEnumerable logs, string period) - { - var grouped = period switch - { - "weekly" => logs.GroupBy(l => GetStartOfWeek(l.Timestamp.Date)), - "monthly" => logs.GroupBy(l => new DateTime(l.Timestamp.Year, l.Timestamp.Month, 1)), - _ => logs.GroupBy(l => l.Timestamp.Date) - }; - - return grouped - .Select(g => new CostTrendPoint - { - Date = g.Key, - Cost = g.Sum(l => l.Cost), - RequestCount = g.Count(), - AverageRequestCost = g.Average(l => l.Cost) - }) - .OrderBy(t => t.Date) - .ToList(); - } - - private List CalculateDailyStatistics(IEnumerable logs, string timeframe) - { - var grouped = timeframe switch - { - "weekly" => logs.GroupBy(l => GetStartOfWeek(l.Timestamp.Date)), - "monthly" => logs.GroupBy(l => new DateTime(l.Timestamp.Year, l.Timestamp.Month, 1)), - _ => logs.GroupBy(l => l.Timestamp.Date) - }; - - return grouped - .Select(g => new DailyStatistics - { - Date = g.Key, - RequestCount = g.Count(), - Cost = g.Sum(l => l.Cost), - InputTokens = g.Sum(l => (long)l.InputTokens), - OutputTokens = g.Sum(l => (long)l.OutputTokens), - AverageResponseTime = g.Average(l => l.ResponseTimeMs), - ErrorCount = g.Count(l => l.StatusCode >= 400) - }) - .OrderBy(s => s.Date) - .ToList(); - } - + /// + /// Compares current period with previous period using database-level summaries. + /// Each period is a single aggregate query instead of loading all rows. + /// private async Task CalculatePreviousPeriodComparison(DateTime startDate, DateTime endDate) { var periodLength = endDate - startDate; var previousStart = startDate - periodLength; var previousEnd = startDate; - var currentLogs = await _requestLogRepository.GetByDateRangeAsync(startDate, endDate); - var previousLogs = await _requestLogRepository.GetByDateRangeAsync(previousStart, previousEnd); + // Two lightweight aggregate queries instead of loading all rows for both periods + var currentTask = _requestLogRepository.GetSummaryAsync(startDate, endDate); + var previousTask = _requestLogRepository.GetSummaryAsync(previousStart, previousEnd); + await Task.WhenAll(currentTask, previousTask); + + var current = await currentTask; + var previous = await previousTask; - var currentCost = currentLogs.Sum(l => l.Cost); - var previousCost = previousLogs.Sum(l => l.Cost); - var currentRequests = currentLogs.Count; - var previousRequests = previousLogs.Count; + var currentErrorRate = current.TotalRequests > 0 + ? current.ErrorCount * 100.0 / current.TotalRequests + : 0; + var previousErrorRate = previous.TotalRequests > 0 + ? previous.ErrorCount * 100.0 / previous.TotalRequests + : 0; return new PeriodComparison { - CostChange = currentCost - previousCost, - CostChangePercentage = previousCost > 0 ? ((currentCost - previousCost) / previousCost * 100) : 0, - RequestChange = currentRequests - previousRequests, - RequestChangePercentage = previousRequests > 0 ? ((decimal)(currentRequests - previousRequests) / previousRequests * 100) : 0, - ResponseTimeChange = currentLogs.Any() && previousLogs.Any() - ? currentLogs.Average(l => l.ResponseTimeMs) - previousLogs.Average(l => l.ResponseTimeMs) + CostChange = current.TotalCost - previous.TotalCost, + CostChangePercentage = previous.TotalCost > 0 + ? ((current.TotalCost - previous.TotalCost) / previous.TotalCost * 100) : 0, - ErrorRateChange = CalculateErrorRateChange(currentLogs, previousLogs) + RequestChange = current.TotalRequests - previous.TotalRequests, + RequestChangePercentage = previous.TotalRequests > 0 + ? ((decimal)(current.TotalRequests - previous.TotalRequests) / previous.TotalRequests * 100) + : 0, + ResponseTimeChange = current.TotalRequests > 0 && previous.TotalRequests > 0 + ? current.AverageResponseTimeMs - previous.AverageResponseTimeMs + : 0, + ErrorRateChange = currentErrorRate - previousErrorRate }; } - private static double CalculateErrorRateChange(IList currentLogs, IList previousLogs) - { - var currentErrorRate = currentLogs.Any() - ? currentLogs.Count(l => l.StatusCode >= 400) * 100.0 / currentLogs.Count - : 0; - var previousErrorRate = previousLogs.Any() - ? previousLogs.Count(l => l.StatusCode >= 400) * 100.0 / previousLogs.Count - : 0; - return currentErrorRate - previousErrorRate; - } - private static byte[] ExportToCsv(IList logs) { var csv = new StringBuilder(); csv.AppendLine("Timestamp,VirtualKeyId,Model,RequestType,InputTokens,OutputTokens,Cost,ResponseTime,StatusCode"); - + foreach (var log in logs) { csv.AppendLine($"{log.Timestamp:yyyy-MM-dd HH:mm:ss},{log.VirtualKeyId},{log.ModelName},{log.RequestType}," + $"{log.InputTokens},{log.OutputTokens},{log.Cost:F6},{log.ResponseTimeMs:F2},{log.StatusCode}"); } - + return Encoding.UTF8.GetBytes(csv.ToString()); } @@ -289,4 +335,4 @@ private class CostTrendPoint #endregion } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Services/AnalyticsService.cs b/Services/ConduitLLM.Admin/Services/AnalyticsService.cs index 628e4e4fd..78c4ab74b 100644 --- a/Services/ConduitLLM.Admin/Services/AnalyticsService.cs +++ b/Services/ConduitLLM.Admin/Services/AnalyticsService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Microsoft.Extensions.Caching.Memory; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Extensions; @@ -17,11 +18,6 @@ public partial class AnalyticsService : IAnalyticsService private readonly IMemoryCache _cache; private readonly ILogger _logger; private readonly IAnalyticsMetrics? _metrics; - - // Cache keys - private const string CachePrefixSummary = "analytics:summary:"; - private const string CachePrefixModels = "analytics:models"; - private const string CachePrefixCostTrend = "analytics:cost:trend:"; // Cache durations private static readonly TimeSpan ShortCacheDuration = TimeSpan.FromMinutes(1); @@ -66,7 +62,7 @@ public async Task> GetLogsAsync( try { - _logger.LogInformation( + _logger.LogDebug( "Getting logs - Page: {Page}, PageSize: {PageSize}, Filters: Model={Model}, VirtualKeyId={VirtualKeyId}, Status={Status}", page, pageSize, model ?? "all", virtualKeyId?.ToString() ?? "all", status?.ToString() ?? "all"); @@ -140,7 +136,7 @@ public async Task> GetLogsAsync( { try { - _logger.LogInformationSecure("Getting log with ID: {LogId}", id); + _logger.LogDebugSecure("Getting log with ID: {LogId}", id); var log = await _requestLogRepository.GetByIdAsync(id); return log != null ? MapToLogRequestDto(log) : null; } @@ -156,34 +152,30 @@ public async Task> GetDistinctModelsAsync() { var stopwatch = Stopwatch.StartNew(); var cacheHit = false; - - var result = await _cache.GetOrCreateAsync(CachePrefixModels, async entry => + + var result = await _cache.GetOrCreateAsync(CacheKeys.Analytics.Models, async entry => { - _metrics?.RecordCacheMiss(CachePrefixModels); + _metrics?.RecordCacheMiss(CacheKeys.Analytics.Models); entry.AbsoluteExpirationRelativeToNow = MediumCacheDuration; - - _logger.LogInformationSecure("Getting distinct models from request logs"); - + + _logger.LogDebugSecure("Getting distinct models from request logs"); + var fetchStopwatch = Stopwatch.StartNew(); - var logs = await _requestLogRepository.GetAllAsync(); - _metrics?.RecordFetchDuration("RequestLogRepository.GetAllAsync", fetchStopwatch.ElapsedMilliseconds); - - return logs - .Where(l => !string.IsNullOrEmpty(l.ModelName)) - .Select(l => l.ModelName) - .Distinct() - .OrderBy(m => m) - .ToList(); + // Use repository-level DISTINCT query instead of loading all logs into memory + var models = await _requestLogRepository.GetDistinctModelsAsync(); + _metrics?.RecordFetchDuration("RequestLogRepository.GetDistinctModelsAsync", fetchStopwatch.ElapsedMilliseconds); + + return models; }); - + if (!cacheHit && result != null) { cacheHit = true; - _metrics?.RecordCacheHit(CachePrefixModels); + _metrics?.RecordCacheHit(CacheKeys.Analytics.Models); } - + _metrics?.RecordOperationDuration("GetDistinctModelsAsync", stopwatch.ElapsedMilliseconds); - + return result ?? Enumerable.Empty(); } diff --git a/Services/ConduitLLM.Admin/Services/CacheManagementService.Configuration.cs b/Services/ConduitLLM.Admin/Services/CacheManagementService.Configuration.cs deleted file mode 100644 index e84a7d67d..000000000 --- a/Services/ConduitLLM.Admin/Services/CacheManagementService.Configuration.cs +++ /dev/null @@ -1,292 +0,0 @@ -using ConduitLLM.Configuration.DTOs.Cache; -using ConduitLLM.Configuration.Events; -using ConduitLLM.Core.Interfaces; -using ConduitLLM.Core.Models; - -namespace ConduitLLM.Admin.Services -{ - /// - /// Configuration management methods for CacheManagementService - /// - public partial class CacheManagementService - { - /// - /// Gets the current cache configuration including all regions and policies. - /// - public async Task GetConfigurationAsync(CancellationToken cancellationToken = default) - { - try - { - var regions = _cacheRegistry.GetAllRegions(); - var cachePolicies = new List(); - var cacheRegions = new List(); - - foreach (var (region, config) in regions) - { - // Get region configuration - var regionConfig = await _configService.GetConfigurationAsync(region.ToString(), cancellationToken); - - // Get region statistics - var stats = await _cacheManager.GetRegionStatisticsAsync(region, cancellationToken); - - // Get applicable policies - var policies = _policyEngine.GetPoliciesForRegion(region); - - // Create cache policy DTOs - var regionPolicies = policies.Select(p => new CachePolicyDto - { - Id = $"{region}-{p.Name}".ToLower().Replace(" ", "-"), - Name = p.Name, - Type = GetPolicyTypeString(p.PolicyType), - TTL = (int)(regionConfig?.DefaultTTL?.TotalSeconds ?? 300), - MaxSize = (int)(regionConfig?.MaxEntries ?? 1000), - Strategy = regionConfig?.EvictionPolicy ?? "LRU", - Enabled = p.IsEnabled, - Description = $"{p.PolicyType} policy for {region} region" - }).ToList(); - - cachePolicies.AddRange(regionPolicies); - - // Create cache region DTO - cacheRegions.Add(new CacheRegionDto - { - Id = region.ToString().ToLower(), - Name = GetRegionDisplayName(region), - Type = regionConfig?.UseDistributedCache == true ? "distributed" : "memory", - Status = stats.HitCount + stats.MissCount > 0 ? "healthy" : "idle", - Nodes = 1, // Would need to query actual node count for distributed cache - Metrics = new CacheMetricsDto - { - Size = FormatSize(stats.TotalSizeBytes), - Items = stats.EntryCount, - HitRate = stats.HitRate * 100, - MissRate = (1 - stats.HitRate) * 100, - EvictionRate = CalculateEvictionRate(stats) - } - }); - } - - // Get overall statistics - var overallStats = await GetOverallStatisticsAsync(cancellationToken); - - return new CacheConfigurationDto - { - Timestamp = DateTime.UtcNow, - CachePolicies = cachePolicies, - CacheRegions = cacheRegions, - Statistics = overallStats, - Configuration = new CacheGlobalConfigDto - { - DefaultTTL = 300, // Default from configuration - MaxMemorySize = "1GB", - EvictionPolicy = "LRU", - CompressionEnabled = true, - RedisConnectionString = "[REDACTED]" // Security: never expose connection strings - } - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get cache configuration"); - throw; - } - } - - /// - /// Updates cache configuration for a specific region or globally. - /// - public async Task UpdateConfigurationAsync(UpdateCacheConfigDto config, CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("Updating cache configuration"); - - // Update global configuration if specified - if (config.ApplyGlobally) - { - foreach (var region in Enum.GetValues()) - { - await UpdateRegionConfigurationAsync(region, config, cancellationToken); - } - } - else if (!string.IsNullOrEmpty(config.RegionId)) - { - // Update specific region - if (Enum.TryParse(config.RegionId, true, out var region)) - { - await UpdateRegionConfigurationAsync(region, config, cancellationToken); - } - else - { - throw new ArgumentException($"Invalid region ID: {config.RegionId}"); - } - } - - // Clear caches if requested - if (config.ClearAffectedCaches) - { - if (config.ApplyGlobally) - { - await _cacheManager.ClearAllAsync(cancellationToken); - } - else if (Enum.TryParse(config.RegionId, true, out var region)) - { - await _cacheManager.ClearRegionAsync(region, cancellationToken); - } - } - - // Publish configuration change event - await _publishEndpoint.Publish(new CacheConfigurationChangedEvent - { - Region = config.RegionId ?? "global", - ChangedBy = "Admin API" - }, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update cache configuration"); - throw; - } - } - - /// - /// Updates the policy configuration for a specific cache region. - /// - public async Task UpdatePolicyAsync(string regionId, UpdateCachePolicyDto policyUpdate, CancellationToken cancellationToken = default) - { - try - { - if (!Enum.TryParse(regionId, true, out var region)) - { - throw new ArgumentException($"Invalid region ID: {regionId}"); - } - - var config = await _configService.GetConfigurationAsync(region.ToString(), cancellationToken); - - if (config == null) - { - config = new ConduitLLM.Configuration.Models.CacheRegionConfig - { - Region = region.ToString(), - Enabled = true - }; - } - - // Update configuration based on policy changes - if (policyUpdate.TTL.HasValue) - { - config.DefaultTTL = TimeSpan.FromSeconds(policyUpdate.TTL.Value); - } - - if (policyUpdate.MaxSize.HasValue) - { - config.MaxEntries = policyUpdate.MaxSize.Value; - } - - if (!string.IsNullOrEmpty(policyUpdate.Strategy)) - { - config.EvictionPolicy = policyUpdate.Strategy; - } - - // Save updated configuration - await _configService.UpdateConfigurationAsync( - region.ToString(), - config, - "Admin API", - $"Policy update: {policyUpdate.Reason}", - cancellationToken); - - _logger.LogInformation("Updated cache policy for region {Region}", region); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update cache policy for region {RegionId}", regionId); - throw; - } - } - - /// - /// Helper method to update region configuration - /// - private async Task UpdateRegionConfigurationAsync(CacheRegion region, UpdateCacheConfigDto config, CancellationToken cancellationToken) - { - var regionConfig = await _configService.GetConfigurationAsync(region.ToString(), cancellationToken); - - if (regionConfig == null) - { - regionConfig = new ConduitLLM.Configuration.Models.CacheRegionConfig - { - Region = region.ToString(), - Enabled = true - }; - } - - if (config.DefaultTTLSeconds.HasValue) - { - regionConfig.DefaultTTL = TimeSpan.FromSeconds(config.DefaultTTLSeconds.Value); - } - - if (!string.IsNullOrEmpty(config.EvictionPolicy)) - { - regionConfig.EvictionPolicy = config.EvictionPolicy; - } - - regionConfig.EnableCompression = config.EnableCompression; - - await _configService.UpdateConfigurationAsync( - region.ToString(), - regionConfig, - "Admin API", - "Configuration update via Admin API", - cancellationToken); - } - - /// - /// Helper method to get policy type string representation - /// - private string GetPolicyTypeString(CachePolicyType policyType) - { - return policyType switch - { - CachePolicyType.TTL => "ttl", - CachePolicyType.Size => "size", - CachePolicyType.Eviction => "eviction", - _ => "custom" - }; - } - - /// - /// Helper method to get display name for cache regions - /// - private string GetRegionDisplayName(CacheRegion region) - { - return region switch - { - CacheRegion.VirtualKeys => "Virtual Key Cache", - CacheRegion.RateLimits => "Rate Limit Cache", - CacheRegion.ProviderHealth => "Provider Health Cache", - CacheRegion.ModelMetadata => "Model Metadata Cache", - CacheRegion.AuthTokens => "Auth Token Cache", - CacheRegion.IpFilters => "IP Filter Cache", - CacheRegion.AsyncTasks => "Async Task Cache", - CacheRegion.ProviderResponses => "Response Cache", - CacheRegion.Embeddings => "Embeddings Cache", - CacheRegion.GlobalSettings => "Global Settings Cache", - CacheRegion.Providers => "Provider Credentials Cache", - CacheRegion.ModelCosts => "Model Cost Cache", - CacheRegion.AudioStreams => "Audio Stream Cache", - CacheRegion.Monitoring => "Monitoring Cache", - _ => region.ToString() - }; - } - - /// - /// Helper method to calculate eviction rate - /// - private double CalculateEvictionRate(CacheRegionStatistics stats) - { - var totalOperations = stats.HitCount + stats.MissCount + stats.SetCount; - return totalOperations > 0 ? (double)stats.EvictionCount / totalOperations * 100 : 0; - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Services/CacheManagementService.Operations.cs b/Services/ConduitLLM.Admin/Services/CacheManagementService.Operations.cs deleted file mode 100644 index 46fa3fdb9..000000000 --- a/Services/ConduitLLM.Admin/Services/CacheManagementService.Operations.cs +++ /dev/null @@ -1,134 +0,0 @@ -using ConduitLLM.Configuration.DTOs.Cache; -using ConduitLLM.Core.Models; - -namespace ConduitLLM.Admin.Services -{ - /// - /// Cache operations methods for CacheManagementService - /// - public partial class CacheManagementService - { - /// - /// Clears a specific cache region or all caches. - /// - public async Task ClearCacheAsync(string cacheId, CancellationToken cancellationToken = default) - { - try - { - _logger.LogInformation("Clearing cache: {CacheId}", cacheId); - - if (cacheId.Equals("all", StringComparison.OrdinalIgnoreCase)) - { - await _cacheManager.ClearAllAsync(cancellationToken); - } - else if (Enum.TryParse(cacheId, true, out var region)) - { - await _cacheManager.ClearRegionAsync(region, cancellationToken); - } - else - { - throw new ArgumentException($"Invalid cache ID: {cacheId}"); - } - - // Log the operation - _logger.LogInformation("Successfully cleared cache: {CacheId}", cacheId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to clear cache {CacheId}", cacheId); - throw; - } - } - - /// - /// Gets entries from a specific cache region with pagination. - /// - public async Task GetEntriesAsync(string regionId, int skip = 0, int take = 100, CancellationToken cancellationToken = default) - { - try - { - if (!Enum.TryParse(regionId, true, out var region)) - { - throw new ArgumentException($"Invalid region ID: {regionId}"); - } - - // Security: Only allow browsing of non-sensitive regions - var sensitiveRegions = new[] { CacheRegion.AuthTokens, CacheRegion.Providers }; - if (sensitiveRegions.Contains(region)) - { - _logger.LogWarning("Attempted to browse sensitive cache region: {Region}", region); - return new CacheEntriesDto - { - RegionId = regionId, - Entries = new List(), - TotalCount = 0, - Message = "Access to this cache region is restricted for security reasons" - }; - } - - var entries = await _cacheManager.GetEntriesAsync(region, skip, take, cancellationToken); - var entryDtos = entries.Select(e => new CacheEntryDto - { - Key = e.Key, - Size = FormatSize(e.SizeInBytes ?? 0), - CreatedAt = e.CreatedAt, - LastAccessedAt = e.LastAccessedAt, - ExpiresAt = e.ExpiresAt, - AccessCount = e.AccessCount, - Priority = e.Priority - }).ToList(); - - var stats = await _cacheManager.GetRegionStatisticsAsync(region, cancellationToken); - - return new CacheEntriesDto - { - RegionId = regionId, - Entries = entryDtos, - TotalCount = (int)stats.EntryCount, - Skip = skip, - Take = take - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get cache entries for region {RegionId}", regionId); - throw; - } - } - - /// - /// Forces a refresh of specific cache entries or an entire region. - /// - public async Task RefreshCacheAsync(string regionId, string? key = null, CancellationToken cancellationToken = default) - { - try - { - if (!Enum.TryParse(regionId, true, out var region)) - { - throw new ArgumentException($"Invalid region ID: {regionId}"); - } - - if (!string.IsNullOrEmpty(key)) - { - // Refresh specific key - var refreshed = await _cacheManager.RefreshAsync(key, region, null, cancellationToken); - if (!refreshed) - { - throw new KeyNotFoundException($"Cache key '{key}' not found in region '{regionId}'"); - } - } - else - { - // Refresh entire region by clearing and allowing repopulation - await _cacheManager.ClearRegionAsync(region, cancellationToken); - _logger.LogInformation("Cleared region {Region} for refresh", region); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh cache for region {RegionId}", regionId); - throw; - } - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Services/CacheManagementService.Statistics.cs b/Services/ConduitLLM.Admin/Services/CacheManagementService.Statistics.cs deleted file mode 100644 index bf2fd4e54..000000000 --- a/Services/ConduitLLM.Admin/Services/CacheManagementService.Statistics.cs +++ /dev/null @@ -1,131 +0,0 @@ -using ConduitLLM.Configuration.DTOs.Cache; -using ConduitLLM.Core.Interfaces; -using ConduitLLM.Core.Models; - -namespace ConduitLLM.Admin.Services -{ - /// - /// Statistics and monitoring methods for CacheManagementService - /// - public partial class CacheManagementService - { - /// - /// Gets statistics for all cache regions or a specific region. - /// - public async Task GetStatisticsAsync(string? regionId = null, CancellationToken cancellationToken = default) - { - try - { - if (string.IsNullOrEmpty(regionId)) - { - return await GetOverallStatisticsAsync(cancellationToken); - } - - if (Enum.TryParse(regionId, true, out var region)) - { - var stats = await _cacheManager.GetRegionStatisticsAsync(region, cancellationToken); - return ConvertToStatisticsDto(stats); - } - - throw new ArgumentException($"Invalid region ID: {regionId}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get cache statistics"); - throw; - } - } - - /// - /// Gets overall statistics across all cache regions - /// - private async Task GetOverallStatisticsAsync(CancellationToken cancellationToken) - { - var allStats = await _cacheManager.GetAllStatisticsAsync(cancellationToken); - - var totalHits = allStats.Sum(s => s.Value.HitCount); - var totalMisses = allStats.Sum(s => s.Value.MissCount); - var totalRequests = totalHits + totalMisses; - var overallHitRate = totalRequests > 0 ? (double)totalHits / totalRequests * 100 : 0; - - var avgGetTime = allStats.Where(s => s.Value.AverageGetTime.TotalMilliseconds > 0) - .Select(s => s.Value.AverageGetTime.TotalMilliseconds) - .DefaultIfEmpty(0) - .Average(); - - var avgSetTime = allStats.Where(s => s.Value.AverageSetTime.TotalMilliseconds > 0) - .Select(s => s.Value.AverageSetTime.TotalMilliseconds) - .DefaultIfEmpty(0) - .Average(); - - return new CacheStatisticsDto - { - TotalHits = totalHits, - TotalMisses = totalMisses, - HitRate = overallHitRate, - AvgResponseTime = new ResponseTimeDto - { - WithCache = (int)avgGetTime, - WithoutCache = (int)(avgGetTime * 20) // Estimate based on typical cache benefit - }, - MemoryUsage = new MemoryUsageDto - { - Current = FormatSize(allStats.Sum(s => s.Value.TotalSizeBytes)), - Peak = FormatSize((long)(allStats.Sum(s => s.Value.TotalSizeBytes) * 1.5)), // Estimate - Limit = "1 GB" - }, - TopCachedItems = await GetTopCachedItemsAsync(cancellationToken) - }; - } - - /// - /// Gets top cached items across regions - /// - private async Task> GetTopCachedItemsAsync(CancellationToken cancellationToken) - { - // This would need a more sophisticated implementation to track individual key statistics - // For now, return sample data based on regions - var topItems = new List(); - - foreach (var region in new[] { CacheRegion.VirtualKeys, CacheRegion.ModelMetadata, CacheRegion.ProviderResponses }) - { - var stats = await _cacheManager.GetRegionStatisticsAsync(region, cancellationToken); - if (stats.HitCount > 0) - { - topItems.Add(new TopCachedItemDto - { - Key = $"{region.ToString().ToLower()}:*", - Hits = stats.HitCount, - Size = FormatSize(stats.TotalSizeBytes / Math.Max(stats.EntryCount, 1)) - }); - } - } - - return topItems.OrderByDescending(i => i.Hits).Take(10).ToList(); - } - - /// - /// Converts cache region statistics to DTO format - /// - private CacheStatisticsDto ConvertToStatisticsDto(CacheRegionStatistics stats) - { - return new CacheStatisticsDto - { - TotalHits = stats.HitCount, - TotalMisses = stats.MissCount, - HitRate = stats.HitRate * 100, - AvgResponseTime = new ResponseTimeDto - { - WithCache = (int)stats.AverageGetTime.TotalMilliseconds, - WithoutCache = (int)(stats.AverageGetTime.TotalMilliseconds * 20) - }, - MemoryUsage = new MemoryUsageDto - { - Current = FormatSize(stats.TotalSizeBytes), - Peak = FormatSize((long)(stats.TotalSizeBytes * 1.5)), - Limit = "N/A" - } - }; - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Services/CacheManagementService.cs b/Services/ConduitLLM.Admin/Services/CacheManagementService.cs deleted file mode 100644 index 6fd346830..000000000 --- a/Services/ConduitLLM.Admin/Services/CacheManagementService.cs +++ /dev/null @@ -1,129 +0,0 @@ -using ConduitLLM.Core.Interfaces; -using ConduitLLM.Configuration.Services; -using ConduitLLM.Configuration.DTOs.Cache; -using ConduitLLM.Configuration.Interfaces; -using MassTransit; - -namespace ConduitLLM.Admin.Services -{ - /// - /// Service for managing cache configuration and operations through the Admin API. - /// - /// - public partial class CacheManagementService : ICacheManagementService - { - private readonly ICacheManager _cacheManager; - private readonly ICacheRegistry _cacheRegistry; - private readonly ICacheConfigurationService _configService; - private readonly ICacheStatisticsCollector _statisticsCollector; - private readonly ICachePolicyEngine _policyEngine; - private readonly ILogger _logger; - private readonly IPublishEndpoint _publishEndpoint; - private readonly IGlobalSettingRepository _globalSettingRepository; - - /// - /// Initializes a new instance of the CacheManagementService. - /// - public CacheManagementService( - ICacheManager cacheManager, - ICacheRegistry cacheRegistry, - ICacheConfigurationService configService, - ICacheStatisticsCollector statisticsCollector, - ICachePolicyEngine policyEngine, - ILogger logger, - IPublishEndpoint publishEndpoint, - IGlobalSettingRepository globalSettingRepository) - { - _cacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager)); - _cacheRegistry = cacheRegistry ?? throw new ArgumentNullException(nameof(cacheRegistry)); - _configService = configService ?? throw new ArgumentNullException(nameof(configService)); - _statisticsCollector = statisticsCollector ?? throw new ArgumentNullException(nameof(statisticsCollector)); - _policyEngine = policyEngine ?? throw new ArgumentNullException(nameof(policyEngine)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint)); - _globalSettingRepository = globalSettingRepository ?? throw new ArgumentNullException(nameof(globalSettingRepository)); - } - - - - /// - /// Helper method to format byte sizes for display - /// - private string FormatSize(long bytes) - { - string[] sizes = { "B", "KB", "MB", "GB", "TB" }; - int order = 0; - double size = bytes; - - while (size >= 1024 && order < sizes.Length - 1) - { - order++; - size /= 1024; - } - - return $"{size:0.##} {sizes[order]}"; - } - } - - /// - /// Interface for cache management operations. - /// - /// - /// Provides operations for managing application cache, including configuration, clearing, - /// refreshing, statistics retrieval and policy updates. Methods are asynchronous to avoid - /// blocking IO-bound work such as distributed cache calls. - /// - public interface ICacheManagementService - { - /// - /// Retrieves the current cache configuration including region policies and TTL defaults. - /// - /// Token to cancel the asynchronous operation. - /// A describing the cache settings. - Task GetConfigurationAsync(CancellationToken cancellationToken = default); - /// - /// Persists a modified cache configuration. - /// - /// New configuration values. - /// Token to cancel the asynchronous operation. - Task UpdateConfigurationAsync(UpdateCacheConfigDto config, CancellationToken cancellationToken = default); - /// - /// Clears all keys belonging to the specified cache region. - /// - /// Identifier of the region or cache instance. - /// Token to cancel the asynchronous operation. - Task ClearCacheAsync(string cacheId, CancellationToken cancellationToken = default); - /// - /// Retrieves aggregated statistics such as hit/miss counts for the whole cache or a single region. - /// - /// Optional region identifier; when null statistics for all regions are returned. - /// Token to cancel the asynchronous operation. - /// Statistics information. - Task GetStatisticsAsync(string? regionId = null, CancellationToken cancellationToken = default); - /// - /// Enumerates cached entries in the specified region with paging support. - /// - /// Target cache region. - /// Number of items to skip for paging. - /// Maximum number of items to return. - /// Token to cancel the asynchronous operation. - Task GetEntriesAsync(string regionId, int skip = 0, int take = 100, CancellationToken cancellationToken = default); - /// - /// Refreshes a single key or an entire region resetting its TTL without changing the value. - /// - /// Region to refresh. - /// Optional specific key; when null the whole region is refreshed. - /// Token to cancel the asynchronous operation. - Task RefreshCacheAsync(string regionId, string? key = null, CancellationToken cancellationToken = default); - /// - /// Updates TTL or eviction policy for a region. - /// - /// Target region. - /// Policy mutation DTO. - /// Token to cancel the asynchronous operation. - Task UpdatePolicyAsync(string regionId, UpdateCachePolicyDto policyUpdate, CancellationToken cancellationToken = default); - - // NOTE: LLM cache methods have been moved to ILLMCacheManagementService - // See Services/LLMCacheManagementService.cs for LLM-specific cache control - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Admin/Services/EphemeralMasterKeyService.cs b/Services/ConduitLLM.Admin/Services/EphemeralMasterKeyService.cs index 7cb1a5e71..53c0d6c42 100644 --- a/Services/ConduitLLM.Admin/Services/EphemeralMasterKeyService.cs +++ b/Services/ConduitLLM.Admin/Services/EphemeralMasterKeyService.cs @@ -1,7 +1,7 @@ -using System.Security.Cryptography; -using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using ConduitLLM.Admin.Models; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Core.Services; namespace ConduitLLM.Admin.Services { @@ -47,12 +47,18 @@ public interface IEphemeralMasterKeyService /// /// Implementation of the ephemeral master key service for Admin API authentication /// - public class EphemeralMasterKeyService : IEphemeralMasterKeyService + public class EphemeralMasterKeyService : EphemeralKeyServiceBase, IEphemeralMasterKeyService { - private readonly IDistributedCache _cache; - private readonly ILogger _logger; - private const string KeyPrefix = "ephemeral:master:"; - private const int TTLSeconds = 300; // 5 minutes + private const int DefaultTTLSeconds = 300; // 5 minutes + + /// + protected override string KeyPrefix => CacheKeys.Ephemeral.MasterPrefix; + + /// + protected override string TokenPrefix => CacheKeys.Ephemeral.MasterTokenPrefix; + + /// + protected override int TTLSeconds => DefaultTTLSeconds; /// /// Initializes a new instance of the class. @@ -62,15 +68,25 @@ public class EphemeralMasterKeyService : IEphemeralMasterKeyService public EphemeralMasterKeyService( IDistributedCache cache, ILogger logger) + : base(cache, logger) { - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + protected override bool IsKeyConsumed(EphemeralMasterKeyData keyData) => keyData.IsConsumed; + + /// + protected override DateTimeOffset GetKeyExpiration(EphemeralMasterKeyData keyData) => keyData.ExpiresAt; + + /// + protected override bool IsKeyValid(EphemeralMasterKeyData keyData) => keyData.IsValid; + + /// + protected override void MarkKeyAsConsumed(EphemeralMasterKeyData keyData) => keyData.IsConsumed = true; + /// public async Task CreateEphemeralMasterKeyAsync() { - // Generate a cryptographically secure token var key = GenerateSecureToken(); var expiresAt = DateTimeOffset.UtcNow.AddSeconds(TTLSeconds); @@ -83,19 +99,9 @@ public async Task CreateEphemeralMasterKeyAsync() IsValid = true }; - // Store in Redis with TTL - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = JsonSerializer.Serialize(keyData); + await StoreKeyDataAsync(key, keyData); - await _cache.SetStringAsync( - cacheKey, - serializedData, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(TTLSeconds) - }); - - _logger.LogInformation("Created ephemeral master key, expires at {ExpiresAt}", expiresAt); + Logger.LogInformation("Created ephemeral master key, expires at {ExpiresAt}", expiresAt); return new EphemeralMasterKeyResponse { @@ -108,177 +114,31 @@ await _cache.SetStringAsync( /// public async Task ValidateAndConsumeKeyAsync(string key) { - if (string.IsNullOrWhiteSpace(key)) - { - _logger.LogDebug("Ephemeral master key validation failed: empty or whitespace key"); - return false; - } - - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = await _cache.GetStringAsync(cacheKey); - - if (string.IsNullOrEmpty(serializedData)) - { - _logger.LogWarning("Ephemeral master key not found: {Key}", SanitizeKeyForLogging(key)); - return false; - } - - var keyData = JsonSerializer.Deserialize(serializedData); + var keyData = await ValidateAndConsumeKeyInternalAsync(key); if (keyData == null) { - _logger.LogError("Failed to deserialize ephemeral master key data for key: {Key}", SanitizeKeyForLogging(key)); - return false; - } - - // Check if already consumed - if (keyData.IsConsumed) - { - _logger.LogWarning("Ephemeral master key already used: {Key}", SanitizeKeyForLogging(key)); + Logger.LogWarning("Failed to validate/consume ephemeral master key: {Key}", + SanitizeKeyForLogging(key)); return false; } - // Check expiration - if (keyData.ExpiresAt < DateTimeOffset.UtcNow) - { - _logger.LogWarning("Ephemeral master key expired: {Key}, expired at {ExpiresAt}", - SanitizeKeyForLogging(key), keyData.ExpiresAt); - // Clean up expired key - await _cache.RemoveAsync(cacheKey); - return false; - } - - // Check validity flag - if (!keyData.IsValid) - { - _logger.LogWarning("Ephemeral master key is not valid: {Key}", SanitizeKeyForLogging(key)); - return false; - } - - // Mark as consumed but keep in cache for cleanup - keyData.IsConsumed = true; - serializedData = JsonSerializer.Serialize(keyData); - - // Update with short TTL for cleanup tracking - await _cache.SetStringAsync( - cacheKey, - serializedData, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30) // Keep for 30s for cleanup - }); - - _logger.LogInformation("Consumed ephemeral master key"); - + Logger.LogInformation("Consumed ephemeral master key"); return true; } /// public async Task ConsumeKeyAsync(string key) { - // Similar to ValidateAndConsumeKeyAsync but doesn't delete - // Used for streaming where we need to maintain the connection - if (string.IsNullOrWhiteSpace(key)) - { - return false; - } - - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = await _cache.GetStringAsync(cacheKey); - - if (string.IsNullOrEmpty(serializedData)) - { - _logger.LogWarning("Ephemeral master key not found for consumption: {Key}", SanitizeKeyForLogging(key)); - return false; - } - - var keyData = JsonSerializer.Deserialize(serializedData); + var keyData = await ConsumeKeyInternalAsync(key); if (keyData == null) { + Logger.LogWarning("Failed to consume ephemeral master key for streaming: {Key}", + SanitizeKeyForLogging(key)); return false; } - if (keyData.IsConsumed) - { - _logger.LogWarning("Attempted to consume already-used ephemeral master key: {Key}", SanitizeKeyForLogging(key)); - return false; - } - - if (keyData.ExpiresAt < DateTimeOffset.UtcNow) - { - _logger.LogWarning("Attempted to consume expired ephemeral master key: {Key}", SanitizeKeyForLogging(key)); - await _cache.RemoveAsync(cacheKey); - return false; - } - - if (!keyData.IsValid) - { - _logger.LogWarning("Attempted to consume invalid ephemeral master key: {Key}", SanitizeKeyForLogging(key)); - return false; - } - - // For streaming, immediately delete the key after successful validation - // The connection itself is now authenticated - await _cache.RemoveAsync(cacheKey); - - _logger.LogInformation("Consumed and deleted ephemeral master key for streaming"); - + Logger.LogInformation("Consumed and deleted ephemeral master key for streaming"); return true; } - - /// - public async Task DeleteKeyAsync(string key) - { - if (string.IsNullOrWhiteSpace(key)) - { - return; - } - - var cacheKey = $"{KeyPrefix}{key}"; - await _cache.RemoveAsync(cacheKey); - - _logger.LogDebug("Deleted ephemeral master key: {Key}", SanitizeKeyForLogging(key)); - } - - /// - public async Task KeyExistsAsync(string key) - { - if (string.IsNullOrWhiteSpace(key)) - { - return false; - } - - var cacheKey = $"{KeyPrefix}{key}"; - var data = await _cache.GetStringAsync(cacheKey); - return !string.IsNullOrEmpty(data); - } - - private static string GenerateSecureToken() - { - const int tokenLength = 32; // 256 bits - var randomBytes = new byte[tokenLength]; - - using (var rng = RandomNumberGenerator.Create()) - { - rng.GetBytes(randomBytes); - } - - // Convert to URL-safe base64 - var token = Convert.ToBase64String(randomBytes) - .Replace('+', '-') - .Replace('/', '_') - .TrimEnd('='); - - // Add prefix - return $"emk_{token}"; - } - - private static string SanitizeKeyForLogging(string key) - { - // Only show first 10 characters of the key for security - if (key.Length <= 10) - return key; - - return $"{key.Substring(0, 10)}..."; - } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Admin/Services/MediaCleanupService.cs b/Services/ConduitLLM.Admin/Services/MediaCleanupService.cs index 887641dde..b9b081557 100644 --- a/Services/ConduitLLM.Admin/Services/MediaCleanupService.cs +++ b/Services/ConduitLLM.Admin/Services/MediaCleanupService.cs @@ -7,6 +7,7 @@ using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Options; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Admin.Metrics; namespace ConduitLLM.Admin.Services { @@ -164,6 +165,7 @@ private async Task AttemptScheduledCleanupAsync(CancellationToken stoppingToken) private async Task RunCleanupAsync(CancellationToken stoppingToken) { var stopwatch = Stopwatch.StartNew(); + using var activity = AdminRequestMetrics.StartMediaCleanupActivity(_instanceId, _options.DryRunMode); var status = "Completed"; using var scope = _serviceScopeFactory.CreateScope(); @@ -196,6 +198,7 @@ private async Task RunCleanupAsync(CancellationToken stoppingToken) await statusService.RecordRunCompletionAsync( 0, 0, stopwatch.Elapsed.TotalSeconds, "No groups", _instanceId, stoppingToken); } + AdminMediaCleanupMetrics.CleanupCycles.WithLabels("no_groups").Inc(); return; } @@ -250,6 +253,17 @@ await statusService.RecordRunCompletionAsync( _instanceId, stoppingToken); } + + // Record Prometheus metrics + AdminMediaCleanupMetrics.CleanupDuration.Observe(stopwatch.Elapsed.TotalSeconds); + AdminMediaCleanupMetrics.GroupsProcessed.Observe(groupIds.Count); + if (totalDeleted > 0) + AdminMediaCleanupMetrics.FilesDeleted.Inc(totalDeleted); + if (totalBytesFreed > 0) + AdminMediaCleanupMetrics.BytesFreed.Inc(totalBytesFreed); + var cleanupStatus = status.StartsWith("Failed") ? "failed" + : status == "Cancelled" ? "cancelled" : "completed"; + AdminMediaCleanupMetrics.CleanupCycles.WithLabels(cleanupStatus).Inc(); } } @@ -274,103 +288,120 @@ await statusService.RecordRunCompletionAsync( return (0, 0); } - // Check for simple retention override first - int retentionDays; - bool respectRecentAccess; - int recentAccessWindowDays; + var retention = await ResolveRetentionSettingsAsync(group, context, stoppingToken); + if (retention == null) + return (0, 0); - var simpleOverride = await GetSimpleRetentionOverrideAsync(stoppingToken); - if (simpleOverride.HasValue) - { - // Simple override is set - use fixed retention for all media - retentionDays = simpleOverride.Value; - respectRecentAccess = false; // Simple mode ignores recent access - recentAccessWindowDays = 0; + var mediaToDelete = await QueryEligibleMediaAsync( + group, retention.Value, context, stoppingToken); + if (mediaToDelete == null || mediaToDelete.Count == 0) + return (0, 0); - _logger.LogDebug( - "Group {GroupId}: Using simple retention override of {Days} days (ignoring balance-based policy)", - groupId, retentionDays); - } - else - { - // No override - use balance-aware policy-based retention - var policy = group.MediaRetentionPolicy ?? await GetDefaultPolicyAsync(context, stoppingToken); - if (policy == null) - { - _logger.LogDebug( - "No retention policy found for group {GroupId} and no default policy exists", - groupId); - return (0, 0); - } + // Process deletions in batches + return await DeleteMediaBatchesAsync( + mediaToDelete, groupId, storageService, budgetService, mediaRepository, stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing cleanup for group {GroupId}", groupId); + AdminMediaCleanupMetrics.CleanupErrors.WithLabels("group_processing").Inc(); + return (0, 0); + } + } - // Calculate retention days based on balance - retentionDays = group.Balance switch - { - > 0 => policy.PositiveBalanceRetentionDays, - 0 => policy.ZeroBalanceRetentionDays, - < 0 => policy.NegativeBalanceRetentionDays - }; - respectRecentAccess = policy.RespectRecentAccess; - recentAccessWindowDays = policy.RecentAccessWindowDays; + /// + /// Resolves retention settings for a group, preferring simple override over policy-based retention. + /// Returns null if no retention settings can be determined. + /// + private async Task<(int retentionDays, bool respectRecentAccess, int recentAccessWindowDays)?> ResolveRetentionSettingsAsync( + VirtualKeyGroup group, + IConfigurationDbContext context, + CancellationToken stoppingToken) + { + var simpleOverride = await GetSimpleRetentionOverrideAsync(stoppingToken); + if (simpleOverride.HasValue) + { + _logger.LogDebug( + "Group {GroupId}: Using simple retention override of {Days} days (ignoring balance-based policy)", + group.Id, simpleOverride.Value); + return (simpleOverride.Value, false, 0); + } - _logger.LogDebug( - "Group {GroupId} balance: {Balance:C}, retention days: {Days} (policy: {PolicyName})", - group.Id, group.Balance, retentionDays, policy.Name); - } + // No override - use balance-aware policy-based retention + var policy = group.MediaRetentionPolicy ?? await GetDefaultPolicyAsync(context, stoppingToken); + if (policy == null) + { + _logger.LogDebug( + "No retention policy found for group {GroupId} and no default policy exists", + group.Id); + return null; + } - // Calculate cutoff date - var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays); + var retentionDays = group.Balance switch + { + > 0 => policy.PositiveBalanceRetentionDays, + 0 => policy.ZeroBalanceRetentionDays, + < 0 => policy.NegativeBalanceRetentionDays + }; - // Get all virtual keys in the group - var virtualKeyIds = await context.VirtualKeys - .Where(vk => vk.VirtualKeyGroupId == groupId) - .Select(vk => vk.Id) - .ToListAsync(stoppingToken); + _logger.LogDebug( + "Group {GroupId} balance: {Balance:C}, retention days: {Days} (policy: {PolicyName})", + group.Id, group.Balance, retentionDays, policy.Name); - if (!virtualKeyIds.Any()) - { - _logger.LogDebug("No virtual keys found in group {GroupId}", group.Id); - return (0, 0); - } + return (retentionDays, policy.RespectRecentAccess, policy.RecentAccessWindowDays); + } - // Query media records eligible for cleanup - var mediaToDelete = await context.MediaRecords - .Where(m => virtualKeyIds.Contains(m.VirtualKeyId)) - .Where(m => m.CreatedAt < cutoffDate) - .Where(m => !respectRecentAccess || - m.LastAccessedAt == null || - m.LastAccessedAt < DateTime.UtcNow.AddDays(-recentAccessWindowDays)) - .ToListAsync(stoppingToken); + /// + /// Queries for media records eligible for cleanup based on retention settings. + /// Returns null if no eligible media found or if manual approval is required for large batches. + /// + private async Task?> QueryEligibleMediaAsync( + VirtualKeyGroup group, + (int retentionDays, bool respectRecentAccess, int recentAccessWindowDays) retention, + IConfigurationDbContext context, + CancellationToken stoppingToken) + { + var cutoffDate = DateTime.UtcNow.AddDays(-retention.retentionDays); - if (!mediaToDelete.Any()) - { - _logger.LogDebug("No media eligible for cleanup in group {GroupId}", group.Id); - return (0, 0); - } + var virtualKeyIds = await context.VirtualKeys + .Where(vk => vk.VirtualKeyGroupId == group.Id) + .Select(vk => vk.Id) + .ToListAsync(stoppingToken); - _logger.LogInformation( - "Found {Count} media files eligible for cleanup in group {GroupId}", - mediaToDelete.Count, group.Id); + if (!virtualKeyIds.Any()) + { + _logger.LogDebug("No virtual keys found in group {GroupId}", group.Id); + return null; + } - // Check if manual approval is required for large batches - if (_options.RequireManualApprovalForLargeBatches && - mediaToDelete.Count > _options.LargeBatchThreshold) - { - _logger.LogWarning( - "Batch of {Count} files exceeds threshold of {Threshold}. Manual approval required. Skipping.", - mediaToDelete.Count, _options.LargeBatchThreshold); - return (0, 0); - } + var mediaToDelete = await context.MediaRecords + .Where(m => virtualKeyIds.Contains(m.VirtualKeyId)) + .Where(m => m.CreatedAt < cutoffDate) + .Where(m => !retention.respectRecentAccess || + m.LastAccessedAt == null || + m.LastAccessedAt < DateTime.UtcNow.AddDays(-retention.recentAccessWindowDays)) + .ToListAsync(stoppingToken); - // Process deletions in batches - return await DeleteMediaBatchesAsync( - mediaToDelete, groupId, storageService, budgetService, mediaRepository, stoppingToken); + if (!mediaToDelete.Any()) + { + _logger.LogDebug("No media eligible for cleanup in group {GroupId}", group.Id); + return null; } - catch (Exception ex) + + _logger.LogInformation( + "Found {Count} media files eligible for cleanup in group {GroupId}", + mediaToDelete.Count, group.Id); + + if (_options.RequireManualApprovalForLargeBatches && + mediaToDelete.Count > _options.LargeBatchThreshold) { - _logger.LogError(ex, "Error processing cleanup for group {GroupId}", groupId); - return (0, 0); + _logger.LogWarning( + "Batch of {Count} files exceeds threshold of {Threshold}. Manual approval required. Skipping.", + mediaToDelete.Count, _options.LargeBatchThreshold); + return null; } + + return mediaToDelete; } private async Task<(int deleted, long bytesFreed)> DeleteMediaBatchesAsync( @@ -513,15 +544,16 @@ private async Task DeleteFromStorageAsync( { try { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); - cts.CancelAfter(TimeSpan.FromSeconds(_options.R2OperationTimeoutSeconds)); + stoppingToken.ThrowIfCancellationRequested(); + // Note: IMediaStorageService.DeleteAsync does not accept a CancellationToken, + // so per-operation timeouts must be enforced by the storage implementation itself. await storageService.DeleteAsync(storageKey); return true; } catch (OperationCanceledException) { - _logger.LogWarning("Storage delete operation timed out for key: {Key}", storageKey); + _logger.LogWarning("Storage delete operation cancelled for key: {Key}", storageKey); return false; } catch (Exception ex) diff --git a/Services/ConduitLLM.Admin/Services/RefundService.cs b/Services/ConduitLLM.Admin/Services/RefundService.cs index 968367582..b6677fcac 100644 --- a/Services/ConduitLLM.Admin/Services/RefundService.cs +++ b/Services/ConduitLLM.Admin/Services/RefundService.cs @@ -45,10 +45,15 @@ public async Task ProcessRefundAsync( string? initiatedByUserId, CancellationToken cancellationToken = default) { + _logger.LogInformation( + "Processing refund for group {GroupId}, model {ModelId}, initiated by {InitiatedBy}", + virtualKeyGroupId, modelId, initiatedBy); + // Validate group exists var group = await _groupRepository.GetByIdAsync(virtualKeyGroupId); if (group == null) { + _logger.LogWarning("Refund rejected: virtual key group {GroupId} not found", virtualKeyGroupId); throw new InvalidOperationException($"Virtual key group {virtualKeyGroupId} not found"); } @@ -65,6 +70,9 @@ public async Task ProcessRefundAsync( if (refundResult.ValidationMessages.Count > 0 && refundResult.RefundAmount == 0) { var errorMessage = string.Join("; ", refundResult.ValidationMessages); + _logger.LogWarning( + "Refund validation failed for group {GroupId}, model {ModelId}: {ValidationErrors}", + virtualKeyGroupId, modelId, errorMessage); throw new ArgumentException($"Refund validation failed: {errorMessage}"); } @@ -88,6 +96,8 @@ public async Task ProcessRefundAsync( CreatedAt = DateTime.UtcNow }; + // Attach and update the group entity (it was fetched with AsNoTracking) + _context.VirtualKeyGroups.Update(group); _context.VirtualKeyGroupTransactions.Add(transaction); await _context.SaveChangesAsync(cancellationToken); diff --git a/Services/ConduitLLM.Admin/Services/SecurityService.cs b/Services/ConduitLLM.Admin/Services/SecurityService.cs index f0f40fab3..dda906879 100644 --- a/Services/ConduitLLM.Admin/Services/SecurityService.cs +++ b/Services/ConduitLLM.Admin/Services/SecurityService.cs @@ -1,97 +1,89 @@ -using System.Net; -using System.Text.Json; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using ConduitLLM.Admin.Options; +using ConduitLLM.Security.Models; +using ConduitLLM.Security.Options; +using ConduitLLM.Security.Services; using ConduitLLM.Admin.Interfaces; namespace ConduitLLM.Admin.Services { /// - /// Implementation of unified security service for Admin API + /// Security service implementation for Admin API. + /// Handles master key authentication, IP banning, rate limiting, and IP filtering. /// - public class SecurityService : ISecurityService + public class SecurityService : SecurityServiceBase, IAdminSecurityService { - private readonly SecurityOptions _options; + private readonly AdminSecurityOptions _options; private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly IMemoryCache _memoryCache; - private readonly IDistributedCache? _distributedCache; - private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IServiceScopeFactory? _serviceScopeFactory; - // Cache keys - same as WebAdmin for shared tracking - private const string RATE_LIMIT_PREFIX = "rate_limit:"; - private const string FAILED_LOGIN_PREFIX = "failed_login:"; - private const string BAN_PREFIX = "ban:"; + /// + protected override string ServiceName => "admin-api"; - // Service identifier for tracking - private const string SERVICE_NAME = "admin-api"; + /// + protected override SecurityOptionsBase Options => _options; /// - /// Initializes a new instance of the SecurityService + /// Initializes a new instance of the Admin SecurityService /// public SecurityService( - IOptions options, + IOptions options, IConfiguration configuration, ILogger logger, IMemoryCache memoryCache, - IDistributedCache? distributedCache, - IServiceScopeFactory serviceScopeFactory) + IDistributedCache? distributedCache = null, + IServiceScopeFactory? serviceScopeFactory = null) + : base(logger, memoryCache, distributedCache) { _options = options.Value; _configuration = configuration; - _logger = logger; - _memoryCache = memoryCache; - _distributedCache = distributedCache; _serviceScopeFactory = serviceScopeFactory; } /// - public async Task IsRequestAllowedAsync(HttpContext context) + public override async Task IsRequestAllowedAsync(HttpContext context) { var clientIp = GetClientIpAddress(context); var path = context.Request.Path.Value ?? ""; - // First check API key authentication (unless excluded path) + // Check API key authentication (unless excluded path) if (!IsPathExcluded(path, new List { "/health", "/swagger", "/scalar", "/openapi", "/hubs" })) { if (!IsApiKeyValid(context)) { - // Record failed auth attempt before returning await RecordFailedAuthAsync(clientIp); - - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "Invalid or missing API key", - StatusCode = 401 - }; + + Logger.LogWarning( + "Authentication failed for {Method} {Path} from {ClientIp}", + context.Request.Method, path, clientIp); + + return SecurityCheckResult.Denied("Invalid or missing API key", 401); } } - // Check if IP is banned due to failed authentication + // Check if IP is banned if (await IsIpBannedAsync(clientIp)) { - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "IP is banned due to excessive failed authentication attempts", - StatusCode = 403 - }; + Logger.LogWarning( + "Banned IP {ClientIp} attempted access to {Method} {Path}", + clientIp, context.Request.Method, path); + + return SecurityCheckResult.Denied("IP is banned due to excessive failed authentication attempts"); } - // Check rate limiting (if enabled) + // Check rate limiting if (_options.RateLimiting.Enabled && !IsPathExcluded(path, _options.RateLimiting.ExcludedPaths)) { - var rateLimitResult = await CheckRateLimitAsync(clientIp); + var rateLimitResult = await CheckIpRateLimitAsync(clientIp); if (!rateLimitResult.IsAllowed) { return rateLimitResult; } } - // Check IP filtering (if enabled) + // Check IP filtering if (_options.IpFiltering.Enabled && !IsPathExcluded(path, _options.IpFiltering.ExcludedPaths)) { var ipFilterResult = await CheckIpFilterAsync(clientIp); @@ -101,18 +93,41 @@ public async Task IsRequestAllowedAsync(HttpContext context } } - return new SecurityCheckResult { IsAllowed = true }; + Logger.LogDebug( + "Request authorized: {Method} {Path} from {ClientIp}", + context.Request.Method, path, clientIp); + + return SecurityCheckResult.Allowed(); } /// public bool ValidateApiKey(string providedKey) { - var masterKey = Environment.GetEnvironmentVariable("CONDUIT_API_TO_API_BACKEND_AUTH_KEY") + var masterKey = Environment.GetEnvironmentVariable("CONDUIT_API_TO_API_BACKEND_AUTH_KEY") ?? _configuration["AdminApi:MasterKey"]; return !string.IsNullOrEmpty(masterKey) && providedKey == masterKey; } + /// + protected override async Task CheckDatabaseIpFilterAsync(string ipAddress) + { + if (_serviceScopeFactory == null) + return SecurityCheckResult.Allowed(); + + using var scope = _serviceScopeFactory.CreateScope(); + var ipFilterService = scope.ServiceProvider.GetRequiredService(); + var isAllowedByDb = await ipFilterService.IsIpAllowedAsync(ipAddress); + + if (!isAllowedByDb) + { + Logger.LogWarning("IP {IpAddress} blocked by database IP filter", ipAddress); + return SecurityCheckResult.Denied("IP address not allowed"); + } + + return SecurityCheckResult.Allowed(); + } + private bool IsApiKeyValid(HttpContext context) { // Check primary header @@ -121,11 +136,9 @@ private bool IsApiKeyValid(HttpContext context) // Check if it's an ephemeral master key (starts with "emk_") if (!string.IsNullOrEmpty(apiKey) && apiKey.ToString().StartsWith("emk_", StringComparison.Ordinal)) { - // Ephemeral keys are validated by the authentication middleware - // We just need to confirm it's present and has the right format return true; } - + if (ValidateApiKey(apiKey!)) return true; } @@ -135,14 +148,11 @@ private bool IsApiKeyValid(HttpContext context) { if (context.Request.Headers.TryGetValue(header, out var altKey)) { - // Check if it's an ephemeral master key (starts with "emk_") if (!string.IsNullOrEmpty(altKey) && altKey.ToString().StartsWith("emk_", StringComparison.Ordinal)) { - // Ephemeral keys are validated by the authentication middleware - // We just need to confirm it's present and has the right format return true; } - + if (ValidateApiKey(altKey!)) return true; } @@ -150,429 +160,5 @@ private bool IsApiKeyValid(HttpContext context) return false; } - - /// - public async Task RecordFailedAuthAsync(string ipAddress) - { -#if DEBUG - // Skip recording failed auth attempts in development mode - _logger.LogDebug("Failed auth recording is disabled in DEBUG mode for IP {IpAddress}", ipAddress); - await Task.CompletedTask; - return; -#else - // Check if IP banning is enabled via configuration - if (!_options.FailedAuth.Enabled) - { - _logger.LogDebug("Failed auth recording is disabled via configuration for IP {IpAddress}", ipAddress); - return; - } - var key = $"{FAILED_LOGIN_PREFIX}{ipAddress}"; - var banKey = $"{BAN_PREFIX}{ipAddress}"; - - // Get current failed attempts - var attempts = 0; - if (_options.UseDistributedTracking && _distributedCache != null) - { - var cachedValue = await _distributedCache.GetStringAsync(key); - if (!string.IsNullOrEmpty(cachedValue)) - { - var data = JsonSerializer.Deserialize(cachedValue); - attempts = data?.Attempts ?? 0; - } - } - else - { - attempts = _memoryCache.Get(key); - } - - attempts++; - - // Check if we should ban the IP - if (attempts >= _options.FailedAuth.MaxAttempts) - { - var banInfo = new BannedIpInfo - { - BannedUntil = DateTime.UtcNow.AddMinutes(_options.FailedAuth.BanDurationMinutes), - FailedAttempts = attempts, - Source = SERVICE_NAME, - Reason = "Exceeded max failed authentication attempts" - }; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.SetStringAsync( - banKey, - JsonSerializer.Serialize(banInfo), - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes) - }); - } - else - { - _memoryCache.Set(banKey, banInfo, TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes)); - } - - _logger.LogWarning("IP {IpAddress} has been banned after {Attempts} failed authentication attempts", - ipAddress, attempts); - - // Clear the failed attempts counter - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.RemoveAsync(key); - } - else - { - _memoryCache.Remove(key); - } - } - else - { - // Update the failed attempts counter - var authData = new FailedAuthData - { - Attempts = attempts, - Source = SERVICE_NAME, - LastAttempt = DateTime.UtcNow - }; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.SetStringAsync( - key, - JsonSerializer.Serialize(authData), - new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes) - }); - } - else - { - _memoryCache.Set(key, attempts, TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes)); - } - - _logger.LogInformation("Failed authentication attempt {Attempts}/{MaxAttempts} for IP {IpAddress}", - attempts, _options.FailedAuth.MaxAttempts, ipAddress); - } -#endif - } - - /// - public async Task ClearFailedAuthAttemptsAsync(string ipAddress) - { - var key = $"{FAILED_LOGIN_PREFIX}{ipAddress}"; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.RemoveAsync(key); - } - else - { - _memoryCache.Remove(key); - } - - _logger.LogInformation("Cleared failed authentication attempts for IP {IpAddress}", ipAddress); - } - - /// - public async Task IsIpBannedAsync(string ipAddress) - { -#if DEBUG - // IP banning is disabled in development mode - _logger.LogDebug("IP banning is disabled in DEBUG mode"); - return await Task.FromResult(false); -#else - // Check if IP banning is enabled via configuration - if (!_options.FailedAuth.Enabled) - { - _logger.LogDebug("IP banning is disabled via configuration"); - return false; - } - var banKey = $"{BAN_PREFIX}{ipAddress}"; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - var cachedValue = await _distributedCache.GetStringAsync(banKey); - if (!string.IsNullOrEmpty(cachedValue)) - { - var banInfo = JsonSerializer.Deserialize(cachedValue); - return banInfo?.BannedUntil > DateTime.UtcNow; - } - } - else - { - var banInfo = _memoryCache.Get(banKey); - return banInfo?.BannedUntil > DateTime.UtcNow; - } - - return false; -#endif - } - - private async Task CheckRateLimitAsync(string ipAddress) - { - var key = $"{RATE_LIMIT_PREFIX}{SERVICE_NAME}:{ipAddress}"; - var now = DateTime.UtcNow; - - // Get current request count - var requestCount = 0; - if (_options.UseDistributedTracking && _distributedCache != null) - { - var cachedValue = await _distributedCache.GetStringAsync(key); - if (!string.IsNullOrEmpty(cachedValue)) - { - var data = JsonSerializer.Deserialize(cachedValue); - requestCount = data?.Count ?? 0; - } - } - else - { - requestCount = _memoryCache.Get(key); - } - - requestCount++; - - if (requestCount > _options.RateLimiting.MaxRequests) - { - _logger.LogWarning("Rate limit exceeded for IP {IpAddress}: {Count} requests in {Window} seconds", - ipAddress, requestCount, _options.RateLimiting.WindowSeconds); - - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "Rate limit exceeded", - StatusCode = 429 - }; - } - - // Update the counter - var rateLimitData = new RateLimitData - { - Count = requestCount, - Source = SERVICE_NAME, - WindowStart = now - }; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.SetStringAsync( - key, - JsonSerializer.Serialize(rateLimitData), - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.RateLimiting.WindowSeconds) - }); - } - else - { - _memoryCache.Set(key, requestCount, TimeSpan.FromSeconds(_options.RateLimiting.WindowSeconds)); - } - - return new SecurityCheckResult { IsAllowed = true }; - } - - private async Task CheckIpFilterAsync(string ipAddress) - { - // Check if it's a private IP and we allow private IPs - if (_options.IpFiltering.AllowPrivateIps) - { - if (IsPrivateIp(ipAddress)) - { - _logger.LogDebug("Private/Intranet IP {IpAddress} is automatically allowed", ipAddress); - return new SecurityCheckResult { IsAllowed = true }; - } - } - - // Check environment variable based filters - var isInWhitelist = _options.IpFiltering.Whitelist.Any(rule => IsIpInRange(ipAddress, rule)); - var isInBlacklist = _options.IpFiltering.Blacklist.Any(rule => IsIpInRange(ipAddress, rule)); - - var isAllowed = _options.IpFiltering.Mode.ToLower() == "restrictive" - ? isInWhitelist && !isInBlacklist - : !isInBlacklist; - - if (!isAllowed) - { - _logger.LogWarning("IP {IpAddress} blocked by IP filter rules", ipAddress); - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "IP address not allowed", - StatusCode = 403 - }; - } - - // Also check database-based IP filters - using var scope = _serviceScopeFactory.CreateScope(); - var ipFilterService = scope.ServiceProvider.GetRequiredService(); - var isAllowedByDb = await ipFilterService.IsIpAllowedAsync(ipAddress); - if (!isAllowedByDb) - { - _logger.LogWarning("IP {IpAddress} blocked by database IP filter", ipAddress); - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "IP address not allowed", - StatusCode = 403 - }; - } - - return new SecurityCheckResult { IsAllowed = true }; - } - - private bool IsPrivateIp(string ipAddress) - { - if (!IPAddress.TryParse(ipAddress, out var ip)) - return false; - - // Check loopback - if (IPAddress.IsLoopback(ip)) - return true; - - // Check private ranges - if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - { - var ipBytes = ip.GetAddressBytes(); - - // Check private ranges - if (ipBytes[0] == 10 || // 10.0.0.0/8 - (ipBytes[0] == 172 && ipBytes[1] >= 16 && ipBytes[1] <= 31) || // 172.16.0.0/12 - (ipBytes[0] == 192 && ipBytes[1] == 168) || // 192.168.0.0/16 - (ipBytes[0] == 169 && ipBytes[1] == 254)) // 169.254.0.0/16 (link-local) - { - return true; - } - } - - return false; - } - - private bool IsIpInRange(string ipAddress, string rule) - { - // Simple IP match - if (ipAddress == rule) - return true; - - // CIDR range check - if (rule.Contains('/')) - { - return IsIpInCidrRange(ipAddress, rule); - } - - return false; - } - - private bool IsPathExcluded(string path, List excludedPaths) - { - return excludedPaths.Any(excluded => path.StartsWith(excluded, StringComparison.OrdinalIgnoreCase)); - } - - private string GetClientIpAddress(HttpContext context) - { - // Check X-Forwarded-For header first (for reverse proxies) - var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - if (!string.IsNullOrEmpty(forwardedFor)) - { - // Take the first IP in the chain - var ip = forwardedFor.Split(',').First().Trim(); - if (IPAddress.TryParse(ip, out _)) - { - return ip; - } - } - - // Check X-Real-IP header - var realIp = context.Request.Headers["X-Real-IP"].FirstOrDefault(); - if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out _)) - { - return realIp; - } - - // Fall back to direct connection IP - return context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - } - - private bool IsIpInCidrRange(string ipAddress, string cidrRange) - { - try - { - var parts = cidrRange.Split('/'); - if (parts.Length != 2) - return false; - - if (!IPAddress.TryParse(ipAddress, out var ip)) - return false; - - if (!IPAddress.TryParse(parts[0], out var baseAddress)) - return false; - - if (!int.TryParse(parts[1], out var prefixLength)) - return false; - - // Only support IPv4 for now - if (ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork || - baseAddress.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) - return false; - - var ipBytes = ip.GetAddressBytes(); - var baseBytes = baseAddress.GetAddressBytes(); - - // Calculate the mask - var maskBytes = new byte[4]; - for (int i = 0; i < 4; i++) - { - if (prefixLength >= 8) - { - maskBytes[i] = 0xFF; - prefixLength -= 8; - } - else if (prefixLength > 0) - { - maskBytes[i] = (byte)(0xFF << (8 - prefixLength)); - prefixLength = 0; - } - else - { - maskBytes[i] = 0x00; - } - } - - // Check if the IP is in the range - for (int i = 0; i < 4; i++) - { - if ((ipBytes[i] & maskBytes[i]) != (baseBytes[i] & maskBytes[i])) - return false; - } - - return true; - } - catch - { - return false; - } - } - - // Data structures for Redis storage (compatible with WebAdmin) - private class FailedAuthData - { - public int Attempts { get; set; } - public string Source { get; set; } = ""; - public DateTime LastAttempt { get; set; } - } - - private class BannedIpInfo - { - public DateTime BannedUntil { get; set; } - public int FailedAttempts { get; set; } - public string Source { get; set; } = ""; - public string Reason { get; set; } = ""; - } - - private class RateLimitData - { - public int Count { get; set; } - public string Source { get; set; } = ""; - public DateTime WindowStart { get; set; } - } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Authentication/BackendAuthenticationHandler.cs b/Services/ConduitLLM.Gateway/Authentication/BackendAuthenticationHandler.cs index 058a512ea..cef973239 100644 --- a/Services/ConduitLLM.Gateway/Authentication/BackendAuthenticationHandler.cs +++ b/Services/ConduitLLM.Gateway/Authentication/BackendAuthenticationHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using ConduitLLM.Core.Utilities; +using ConduitLLM.Gateway.Metrics; namespace ConduitLLM.Gateway.Authentication { @@ -33,12 +34,14 @@ protected override Task HandleAuthenticateAsync() if (string.IsNullOrEmpty(_backendAuthKey)) { Logger.LogWarning("Backend authentication key is not configured"); + GatewayAuthMetrics.RecordFailure("Backend", "not_configured"); return Task.FromResult(AuthenticateResult.Fail("Backend authentication not configured")); } // Check for the Authorization header if (!Request.Headers.ContainsKey("Authorization")) { + GatewayAuthMetrics.RecordNoResult("Backend"); return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header")); } @@ -48,6 +51,7 @@ protected override Task HandleAuthenticateAsync() if (string.IsNullOrEmpty(providedKey)) { + GatewayAuthMetrics.RecordFailure("Backend", "invalid_format"); return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format")); } @@ -55,6 +59,7 @@ protected override Task HandleAuthenticateAsync() if (providedKey != _backendAuthKey) { Logger.LogWarning("Invalid backend authentication key provided"); + GatewayAuthMetrics.RecordFailure("Backend", "invalid_key"); return Task.FromResult(AuthenticateResult.Fail("Invalid authentication key")); } @@ -70,6 +75,7 @@ protected override Task HandleAuthenticateAsync() var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); + GatewayAuthMetrics.RecordSuccess("Backend"); return Task.FromResult(AuthenticateResult.Success(ticket)); } } diff --git a/Services/ConduitLLM.Gateway/Authentication/EphemeralKeyAuthenticationHandler.cs b/Services/ConduitLLM.Gateway/Authentication/EphemeralKeyAuthenticationHandler.cs index 5930ccea6..4437022c7 100644 --- a/Services/ConduitLLM.Gateway/Authentication/EphemeralKeyAuthenticationHandler.cs +++ b/Services/ConduitLLM.Gateway/Authentication/EphemeralKeyAuthenticationHandler.cs @@ -1,9 +1,11 @@ +using System.Diagnostics; using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Utilities; +using ConduitLLM.Gateway.Metrics; using ConduitLLM.Gateway.Services; namespace ConduitLLM.Gateway.Authentication @@ -30,6 +32,8 @@ public EphemeralKeyAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { + var stopwatch = Stopwatch.StartNew(); + // Skip authentication for OPTIONS requests (CORS preflight) if (Request.Method == "OPTIONS") { @@ -39,14 +43,16 @@ protected override async Task HandleAuthenticateAsync() // Check for ephemeral key in X-Ephemeral-Key header if (!Request.Headers.ContainsKey("X-Ephemeral-Key")) { + GatewayAuthMetrics.RecordNoResult("EphemeralKey"); return AuthenticateResult.NoResult(); } var ephemeralKey = Request.Headers["X-Ephemeral-Key"].ToString(); - + if (string.IsNullOrEmpty(ephemeralKey)) { Logger.LogWarning("Empty ephemeral key provided in X-Ephemeral-Key header"); + GatewayAuthMetrics.RecordFailure("EphemeralKey", "empty_key"); return AuthenticateResult.Fail("Invalid ephemeral key"); } @@ -58,22 +64,25 @@ protected override async Task HandleAuthenticateAsync() if (string.IsNullOrEmpty(actualVirtualKey)) { Logger.LogWarning("Ephemeral key not found or invalid: {Key}", SanitizeKeyForLogging(ephemeralKey)); + GatewayAuthMetrics.RecordFailure("EphemeralKey", "not_found"); return AuthenticateResult.Fail("Ephemeral key not found"); } - + // Get the virtual key ID from the ephemeral key data without consuming it // The key will naturally expire via Redis TTL var keyData = await _ephemeralKeyService.GetKeyDataAsync(ephemeralKey); if (keyData == null) { Logger.LogWarning("Ephemeral key not found: {Key}", SanitizeKeyForLogging(ephemeralKey)); + GatewayAuthMetrics.RecordFailure("EphemeralKey", "not_found"); return AuthenticateResult.Fail("Ephemeral key not found"); } - + // Check if expired if (keyData.ExpiresAt < DateTimeOffset.UtcNow) { Logger.LogWarning("Ephemeral key expired: {Key}", SanitizeKeyForLogging(ephemeralKey)); + GatewayAuthMetrics.RecordFailure("EphemeralKey", "expired"); return AuthenticateResult.Fail("Ephemeral key expired"); } @@ -94,6 +103,7 @@ protected override async Task HandleAuthenticateAsync() Logger.LogWarning("Virtual key {VirtualKeyId} exists with name '{KeyName}' but validation failed - likely missing hash", virtualKeyId.Value, basicInfo.KeyName); } + GatewayAuthMetrics.RecordFailure("EphemeralKey", "virtualkey_not_found"); return AuthenticateResult.Fail("Associated virtual key not found"); } @@ -101,6 +111,7 @@ protected override async Task HandleAuthenticateAsync() if (!virtualKeyInfo.IsEnabled) { Logger.LogWarning("Inactive virtual key {VirtualKeyId} used with ephemeral key", virtualKeyId.Value); + GatewayAuthMetrics.RecordFailure("EphemeralKey", "virtualkey_disabled"); return AuthenticateResult.Fail("Associated virtual key is inactive"); } @@ -130,9 +141,12 @@ protected override async Task HandleAuthenticateAsync() var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); - Logger.LogInformation("Ephemeral key authenticated for virtual key {VirtualKeyId} ({VirtualKeyName}), streaming: {IsStreaming}", + Logger.LogInformation("Ephemeral key authenticated for virtual key {VirtualKeyId} ({VirtualKeyName}), streaming: {IsStreaming}", virtualKeyInfo.Id, virtualKeyInfo.KeyName, isStreaming); + stopwatch.Stop(); + GatewayAuthMetrics.RecordSuccess("EphemeralKey"); + GatewayAuthMetrics.RecordDuration("EphemeralKey", stopwatch.Elapsed.TotalSeconds); return AuthenticateResult.Success(ticket); } diff --git a/Services/ConduitLLM.Gateway/Authentication/VirtualKeyAuthenticationHandler.cs b/Services/ConduitLLM.Gateway/Authentication/VirtualKeyAuthenticationHandler.cs index 1c57389dc..e08d05965 100644 --- a/Services/ConduitLLM.Gateway/Authentication/VirtualKeyAuthenticationHandler.cs +++ b/Services/ConduitLLM.Gateway/Authentication/VirtualKeyAuthenticationHandler.cs @@ -1,10 +1,14 @@ +using System.Diagnostics; using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Utilities; +using ConduitLLM.Gateway.Metrics; using ConduitLLM.Gateway.Services; +using Prometheus; namespace ConduitLLM.Gateway.Authentication { @@ -36,6 +40,9 @@ public VirtualKeyAuthenticationHandler( /// protected override async Task HandleAuthenticateAsync() { + using var activity = GatewayRequestMetrics.StartAuthenticationActivity("VirtualKey"); + var stopwatch = Stopwatch.StartNew(); + try { // Skip authentication for excluded paths @@ -50,48 +57,52 @@ protected override async Task HandleAuthenticateAsync() // Extract Virtual Key from request var providedKey = ExtractVirtualKey(Context); - + if (string.IsNullOrEmpty(providedKey)) { // Return NoResult to allow other authentication schemes to be tried // Only log at Debug level since this is expected when using other auth schemes - Logger.LogDebug("No Virtual Key found in request to {Path} from IP {IP}", + Logger.LogDebug("No Virtual Key found in request to {Path} from IP {IP}", Context.Request.Path, GetClientIpAddress(Context)); + GatewayAuthMetrics.RecordNoResult("VirtualKey"); return AuthenticateResult.NoResult(); } // Check if this is an ephemeral key (starts with "ek_") string? virtualKey; bool isEphemeralKey = false; - + if (providedKey.StartsWith("ek_", StringComparison.Ordinal)) { isEphemeralKey = true; Logger.LogDebug("Processing ephemeral key authentication"); - + // Get the virtual key data without consuming var keyData = await _ephemeralKeyService.GetKeyDataAsync(providedKey); if (keyData == null) { Logger.LogWarning("Ephemeral key not found: {Key}", SanitizeKeyForLogging(providedKey)); + GatewayAuthMetrics.RecordFailure("VirtualKey", "ephemeral_not_found"); return AuthenticateResult.Fail("Invalid ephemeral key"); } - + // Check if expired if (keyData.ExpiresAt < DateTimeOffset.UtcNow) { Logger.LogWarning("Ephemeral key expired: {Key}", SanitizeKeyForLogging(providedKey)); + GatewayAuthMetrics.RecordFailure("VirtualKey", "ephemeral_expired"); return AuthenticateResult.Fail("Ephemeral key expired"); } - + // Get the actual virtual key virtualKey = await _ephemeralKeyService.GetVirtualKeyAsync(providedKey); if (string.IsNullOrEmpty(virtualKey)) { Logger.LogWarning("Could not retrieve virtual key from ephemeral key: {Key}", SanitizeKeyForLogging(providedKey)); + GatewayAuthMetrics.RecordFailure("VirtualKey", "ephemeral_invalid"); return AuthenticateResult.Fail("Invalid ephemeral key"); } - + Logger.LogInformation("Ephemeral key validated, using virtual key ID {VirtualKeyId}", keyData.VirtualKeyId); } else @@ -100,13 +111,14 @@ protected override async Task HandleAuthenticateAsync() virtualKey = providedKey; } - // Validate the Virtual Key for authentication only (no balance check) + // Validate the Virtual Key for authentication only (no balance check) // virtualKey is guaranteed to be non-null at this point due to earlier validation var keyEntity = await _virtualKeyService.ValidateVirtualKeyForAuthenticationAsync(virtualKey!); if (keyEntity == null) { - Logger.LogWarning("Invalid Virtual Key in request to {Path} from IP {IP}", + Logger.LogWarning("Invalid Virtual Key in request to {Path} from IP {IP}", Context.Request.Path, GetClientIpAddress(Context)); + GatewayAuthMetrics.RecordFailure("VirtualKey", "invalid_key"); return AuthenticateResult.Fail("Invalid Virtual Key"); } @@ -126,7 +138,14 @@ protected override async Task HandleAuthenticateAsync() Context.Items["VirtualKeyId"] = keyEntity.Id; Context.Items["VirtualKey"] = virtualKey; Context.Items["RequestStartTime"] = DateTime.UtcNow; - + + // Store rate-limit config so VirtualKeyRateLimitMiddleware can enforce limits + // without re-fetching the key. KeyHash is the partition key for rate limiting; + // null RPM/RPD means "unlimited" for this key. + Context.Items["VirtualKey.KeyHash"] = keyEntity.KeyHash; + Context.Items["VirtualKey.RateLimitRpm"] = keyEntity.RateLimitRpm; + Context.Items["VirtualKey.RateLimitRpd"] = keyEntity.RateLimitRpd; + // Store ephemeral key status for logging/auditing if (isEphemeralKey) { @@ -138,16 +157,27 @@ protected override async Task HandleAuthenticateAsync() Context.Items["AuthType"] = "VirtualKey"; } - Logger.LogDebug("Successfully authenticated Virtual Key {KeyName} for {Path}", - keyEntity.KeyName, Context.Request.Path); + activity?.SetTag("gateway.virtual_key_id", keyEntity.Id); + activity?.SetTag("gateway.auth_result", "success"); + Logger.LogDebug("Successfully authenticated Virtual Key {KeyName} for {Path}", + LoggingSanitizer.S(keyEntity.KeyName), LoggingSanitizer.S(Context.Request.Path.ToString())); + GatewayAuthMetrics.RecordSuccess("VirtualKey"); return AuthenticateResult.Success(ticket); } catch (Exception ex) { - Logger.LogError(ex, "Error during Virtual Key authentication for {Path}", Context.Request.Path); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("gateway.auth_result", "error"); + Logger.LogError(ex, "Error during Virtual Key authentication for {Path}", LoggingSanitizer.S(Context.Request.Path.ToString())); + GatewayAuthMetrics.RecordError("VirtualKey"); return AuthenticateResult.Fail("Authentication error"); } + finally + { + stopwatch.Stop(); + GatewayAuthMetrics.RecordDuration("VirtualKey", stopwatch.Elapsed.TotalSeconds); + } } /// diff --git a/Services/ConduitLLM.Gateway/Authentication/VirtualKeyHubFilter.cs b/Services/ConduitLLM.Gateway/Authentication/VirtualKeyHubFilter.cs index 2ce9808ab..f0d751633 100644 --- a/Services/ConduitLLM.Gateway/Authentication/VirtualKeyHubFilter.cs +++ b/Services/ConduitLLM.Gateway/Authentication/VirtualKeyHubFilter.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.SignalR; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Utilities; @@ -64,7 +65,7 @@ public VirtualKeyHubFilter( invocationContext.Context.Items["VirtualKey"] = virtualKey; _logger.LogDebug("Authenticated Virtual Key {KeyName} for method {Method}", - keyEntity.KeyName, invocationContext.HubMethodName); + LoggingSanitizer.S(keyEntity.KeyName), invocationContext.HubMethodName); return await next(invocationContext); } @@ -105,7 +106,7 @@ public async Task OnConnectedAsync(HubLifetimeContext context, Functrue Default true - $(NoWarn);1591 + + $(NoWarn);1591;NU1510 @@ -24,44 +25,50 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + + + + + + - - + + - - - - + + + + - + + - + - + - - - - - - - + + + + + + + - + + diff --git a/Services/ConduitLLM.Gateway/Constants/HttpContextKeys.cs b/Services/ConduitLLM.Gateway/Constants/HttpContextKeys.cs index f8fc82629..79ef2d417 100644 --- a/Services/ConduitLLM.Gateway/Constants/HttpContextKeys.cs +++ b/Services/ConduitLLM.Gateway/Constants/HttpContextKeys.cs @@ -56,73 +56,8 @@ public static class HttpContextKeys /// public const string ModelCostId = "ModelCostId"; - /// - /// Key for storing the model name from image generation request (before provider mapping). - /// Value type: string - /// - public const string ImageRequestModel = "ImageRequestModel"; - - /// - /// Key for storing the quality setting from image generation request. - /// Value type: string (e.g., "standard", "hd") - /// - public const string ImageRequestQuality = "ImageRequestQuality"; - - /// - /// Key for storing the size/resolution from image generation request. - /// Value type: string (e.g., "1024x1024", "1792x1024") - /// - public const string ImageRequestSize = "ImageRequestSize"; - - /// - /// Key for storing the number of images requested. - /// Value type: int - /// - public const string ImageRequestN = "ImageRequestN"; - - #region Video Request Keys - - /// - /// Key for storing the model name from video generation request (before provider mapping). - /// Value type: string - /// - public const string VideoRequestModel = "VideoRequestModel"; - - /// - /// Key for storing the size/resolution from video generation request. - /// Value type: string (e.g., "1920x1080", "1280x720") - /// - public const string VideoRequestSize = "VideoRequestSize"; - - /// - /// Key for storing the duration from video generation request. - /// Value type: int (seconds) - /// - public const string VideoRequestDuration = "VideoRequestDuration"; - - /// - /// Key for storing the FPS from video generation request. - /// Value type: int - /// - public const string VideoRequestFps = "VideoRequestFps"; - - /// - /// Key for storing the style from video generation request. - /// Value type: string - /// - public const string VideoRequestStyle = "VideoRequestStyle"; - - /// - /// Key for storing the number of videos requested. - /// Value type: int - /// - public const string VideoRequestN = "VideoRequestN"; - - /// - /// Key for storing additional pricing parameters extracted from video generation request. - /// Value type: Dictionary<string, object> - /// - public const string VideoRequestPricingParameters = "VideoRequestPricingParameters"; - - #endregion + // NOTE: Image and video request-shape data (model, size, quality, duration, fps, style, + // N, pricing parameters) is no longer carried via string keys. It now flows through the + // typed ConduitLLM.Gateway.Usage.IUsageContext set by ImagesController/VideosController + // and consumed by UsageTrackingMiddleware. } diff --git a/Services/ConduitLLM.Gateway/Consumers/ModelCostCacheInvalidationHandler.cs b/Services/ConduitLLM.Gateway/Consumers/ModelCostCacheInvalidationHandler.cs index bb258e996..acb7acd86 100644 --- a/Services/ConduitLLM.Gateway/Consumers/ModelCostCacheInvalidationHandler.cs +++ b/Services/ConduitLLM.Gateway/Consumers/ModelCostCacheInvalidationHandler.cs @@ -63,14 +63,22 @@ public async Task Consume(ConsumeContext context) @event.CostName); } - // Invalidate model cost cache if available + // Invalidate model cost cache if available โ€” use targeted invalidation to avoid cache miss avalanche if (_modelCostCache != null) { try { - // Clear all model costs to ensure cache consistency - await _modelCostCache.ClearAllModelCostsAsync(); - _logger.LogInformation("Model cost cache cleared due to cost change event"); + if (@event.ModelCostId > 0) + { + await _modelCostCache.InvalidateModelCostAsync(@event.ModelCostId); + _logger.LogInformation("Model cost cache invalidated for ModelCostId: {ModelCostId}", @event.ModelCostId); + } + else + { + // Fallback to full clear only when we don't have a specific ID + await _modelCostCache.ClearAllModelCostsAsync(); + _logger.LogInformation("Model cost cache fully cleared (no specific ModelCostId in event)"); + } } catch (Exception ex) { @@ -83,7 +91,7 @@ public async Task Consume(ConsumeContext context) { try { - _pricingRulesCache.InvalidateCache(@event.ModelCostId); + await _pricingRulesCache.InvalidateCacheAsync(@event.ModelCostId); _logger.LogInformation( "Pricing rules cache invalidated for ModelCostId: {ModelCostId}", @event.ModelCostId); diff --git a/Services/ConduitLLM.Gateway/Consumers/ModelMappingCacheInvalidationConsumer.cs b/Services/ConduitLLM.Gateway/Consumers/ModelMappingCacheInvalidationHandler.cs similarity index 88% rename from Services/ConduitLLM.Gateway/Consumers/ModelMappingCacheInvalidationConsumer.cs rename to Services/ConduitLLM.Gateway/Consumers/ModelMappingCacheInvalidationHandler.cs index 28a00c942..251bfdc74 100644 --- a/Services/ConduitLLM.Gateway/Consumers/ModelMappingCacheInvalidationConsumer.cs +++ b/Services/ConduitLLM.Gateway/Consumers/ModelMappingCacheInvalidationHandler.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; @@ -32,22 +33,19 @@ namespace ConduitLLM.Gateway.Consumers /// This ensures that all API endpoints using cached mappings will get fresh data /// on the next request after a configuration change. /// - public class ModelMappingCacheInvalidationConsumer : IConsumer + public class ModelMappingCacheInvalidationHandler : IConsumer { private readonly ICacheManager _cacheManager; private readonly IDiscoveryCacheService _discoveryCacheService; - private readonly ILogger _logger; + private readonly ILogger _logger; // Cache configuration - must match CachedModelProviderMappingService private const CacheRegion Region = CacheRegion.ModelMetadata; - private const string ByAliasKeyPattern = "model:mapping:{0}"; - private const string ByIdKeyPattern = "model:mapping:id:{0}"; - private const string AllMappingsKey = "model:mapping:all"; - public ModelMappingCacheInvalidationConsumer( + public ModelMappingCacheInvalidationHandler( ICacheManager cacheManager, IDiscoveryCacheService discoveryCacheService, - ILogger logger) + ILogger logger) { _cacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager)); _discoveryCacheService = discoveryCacheService ?? throw new ArgumentNullException(nameof(discoveryCacheService)); @@ -81,16 +79,16 @@ private async Task InvalidateModelMappingCacheAsync(ModelMappingChanged @event) var keysToRemove = new List(); // Always invalidate the ID-based key - keysToRemove.Add(string.Format(ByIdKeyPattern, @event.MappingId)); + keysToRemove.Add(CacheKeys.ModelMapping.ById(@event.MappingId)); // Invalidate alias-based key (primary lookup path for most operations) if (!string.IsNullOrEmpty(@event.ModelAlias)) { - keysToRemove.Add(string.Format(ByAliasKeyPattern, @event.ModelAlias)); + keysToRemove.Add(CacheKeys.ModelMapping.ByAlias(@event.ModelAlias)); } // Invalidate the "all mappings" cache - keysToRemove.Add(AllMappingsKey); + keysToRemove.Add(CacheKeys.ModelMapping.AllMappings); // Perform cache invalidation var removed = await _cacheManager.RemoveManyAsync(keysToRemove, Region); diff --git a/Services/ConduitLLM.Gateway/Consumers/ProviderToolCacheInvalidationHandler.cs b/Services/ConduitLLM.Gateway/Consumers/ProviderToolCacheInvalidationHandler.cs new file mode 100644 index 000000000..ee9855b5b --- /dev/null +++ b/Services/ConduitLLM.Gateway/Consumers/ProviderToolCacheInvalidationHandler.cs @@ -0,0 +1,63 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Core.Events; +using ConduitLLM.Core.Interfaces; +using MassTransit; + +namespace ConduitLLM.Gateway.Consumers; + +/// +/// Handles ProviderToolChanged events for cache invalidation. +/// Invalidates the provider tool cache when tools are created, updated, or deleted. +/// +public class ProviderToolCacheInvalidationHandler : IConsumer +{ + private readonly IProviderToolCache? _providerToolCache; + private readonly ILogger _logger; + + public ProviderToolCacheInvalidationHandler( + IProviderToolCache? providerToolCache, + ILogger logger) + { + _providerToolCache = providerToolCache; + _logger = logger; + } + + public async Task Consume(ConsumeContext context) + { + var @event = context.Message; + + _logger.LogInformation( + "ProviderToolChanged event received - ToolId: {ToolId}, ToolName: {ToolName}, Provider: {Provider}, ChangeType: {ChangeType}", + @event.ProviderToolId, + @event.ToolName, + @event.ProviderType, + @event.ChangeType); + + if (_providerToolCache == null) + { + _logger.LogDebug("Provider tool cache not available, skipping invalidation"); + return; + } + + try + { + if (Enum.TryParse(@event.ProviderType, true, out var providerType)) + { + await _providerToolCache.InvalidateProviderAsync(providerType); + _logger.LogInformation("Provider tool cache invalidated for {ProviderType} due to {ChangeType} event", + providerType, @event.ChangeType); + } + else + { + // Unknown provider type โ€” clear all to be safe + await _providerToolCache.ClearAllAsync(); + _logger.LogWarning("Unknown provider type '{ProviderType}' in event, cleared all provider tool cache entries", + @event.ProviderType); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error invalidating provider tool cache for {ProviderType}", @event.ProviderType); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Controllers/AuthController.cs b/Services/ConduitLLM.Gateway/Controllers/AuthController.cs index 5efd0f90b..3f54c4db2 100644 --- a/Services/ConduitLLM.Gateway/Controllers/AuthController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/AuthController.cs @@ -1,3 +1,6 @@ +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Models; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ConduitLLM.Gateway.Models; @@ -11,17 +14,16 @@ namespace ConduitLLM.Gateway.Controllers [ApiController] [Route("v1/auth")] [Tags("Authentication")] - public class AuthController : ControllerBase + public class AuthController : GatewayControllerBase { private readonly IEphemeralKeyService _ephemeralKeyService; - private readonly ILogger _logger; public AuthController( IEphemeralKeyService ephemeralKeyService, ILogger logger) + : base(logger) { _ephemeralKeyService = ephemeralKeyService ?? throw new ArgumentNullException(nameof(ephemeralKeyService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -35,21 +37,25 @@ public AuthController( [HttpPost("ephemeral-key")] [Authorize(AuthenticationSchemes = "VirtualKey")] [ProducesResponseType(typeof(EphemeralKeyResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] - public async Task GenerateEphemeralKey([FromBody] GenerateEphemeralKeyRequest? request = null) + [ProducesResponseType(typeof(OpenAIErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(OpenAIErrorResponse), StatusCodes.Status500InternalServerError)] + public Task GenerateEphemeralKey([FromBody] GenerateEphemeralKeyRequest? request = null) { - try + return ExecuteAsync(async () => { // Get virtual key ID from claims var virtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; if (string.IsNullOrEmpty(virtualKeyIdClaim) || !int.TryParse(virtualKeyIdClaim, out int virtualKeyId)) { - _logger.LogWarning("Failed to extract virtual key ID from claims"); - return Unauthorized(new ProblemDetails + Logger.LogWarning("Failed to extract virtual key ID from claims"); + return Unauthorized(new OpenAIErrorResponse { - Title = "Unauthorized", - Detail = "Virtual key not found in request context" + Error = new OpenAIError + { + Message = "Virtual key not found in request context", + Type = "authentication_error", + Code = "unauthorized" + } }); } @@ -57,33 +63,28 @@ public async Task GenerateEphemeralKey([FromBody] GenerateEphemer var virtualKey = HttpContext.User.FindFirst("VirtualKey")?.Value; if (string.IsNullOrEmpty(virtualKey)) { - _logger.LogWarning("Failed to extract virtual key from claims"); - return Unauthorized(new ProblemDetails + Logger.LogWarning("Failed to extract virtual key from claims"); + return Unauthorized(new OpenAIErrorResponse { - Title = "Unauthorized", - Detail = "Virtual key not found in request context" + Error = new OpenAIError + { + Message = "Virtual key not found in request context", + Type = "authentication_error", + Code = "unauthorized" + } }); } // Create ephemeral key with the actual virtual key var response = await _ephemeralKeyService.CreateEphemeralKeyAsync( - virtualKeyId, + virtualKeyId, virtualKey, request?.Metadata); - _logger.LogInformation("Generated ephemeral key for virtual key {VirtualKeyId}", virtualKeyId); + Logger.LogInformation("Generated ephemeral key for virtual key {VirtualKeyId}", virtualKeyId); return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to generate ephemeral key"); - return StatusCode(500, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "Failed to generate ephemeral key" - }); - } + }, nameof(GenerateEphemeralKey)); } } diff --git a/Services/ConduitLLM.Gateway/Controllers/BatchOperationsController.cs b/Services/ConduitLLM.Gateway/Controllers/BatchOperationsController.cs index 571a162a0..db3d815e0 100644 --- a/Services/ConduitLLM.Gateway/Controllers/BatchOperationsController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/BatchOperationsController.cs @@ -1,10 +1,11 @@ using Microsoft.AspNetCore.Authorization; -using ConduitLLM.Configuration.DTOs; using Microsoft.AspNetCore.Mvc; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; +using ConduitLLM.Core.Controllers; using ConduitLLM.Configuration.DTOs.BatchOperations; using ConduitLLM.Core.Services.BatchOperations; +using GatewayOpsMetrics = ConduitLLM.Gateway.Services.GatewayOperationsMetricsService; namespace ConduitLLM.Gateway.Controllers { @@ -15,9 +16,8 @@ namespace ConduitLLM.Gateway.Controllers [ApiController] [Route("v1/batch")] [Authorize] - public class BatchOperationsController : ControllerBase + public class BatchOperationsController : GatewayControllerBase { - private readonly ILogger _logger; private readonly IBatchOperationService _batchOperationService; private readonly IBatchVirtualKeyUpdateOperation _batchVirtualKeyUpdateOperation; private readonly IBatchWebhookSendOperation _batchWebhookSendOperation; @@ -31,8 +31,8 @@ public BatchOperationsController( IBatchWebhookSendOperation batchWebhookSendOperation, IVirtualKeyService virtualKeyService, BatchSpendUpdateOperation batchSpendUpdateOperation) + : base(logger) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _batchOperationService = batchOperationService ?? throw new ArgumentNullException(nameof(batchOperationService)); _batchVirtualKeyUpdateOperation = batchVirtualKeyUpdateOperation ?? throw new ArgumentNullException(nameof(batchVirtualKeyUpdateOperation)); _batchWebhookSendOperation = batchWebhookSendOperation ?? throw new ArgumentNullException(nameof(batchWebhookSendOperation)); @@ -56,54 +56,74 @@ public BatchOperationsController( [ProducesResponseType(401)] public async Task StartBatchSpendUpdate([FromBody] BatchSpendUpdateRequest request) { - var virtualKeyId = GetVirtualKeyId(); - - // Validate request - if (request.Updates == null || request.Updates.Count() == 0) + return await ExecuteAsync(async () => { - return BadRequest(new ErrorResponseDto("No updates provided")); - } + var virtualKeyId = GetVirtualKeyId(); - if (request.Updates.Count() > 10000) - { - return BadRequest(new ErrorResponseDto("Maximum 10,000 items per batch")); - } + // Validate request + if (request.Updates == null || !request.Updates.Any()) + { + return BadRequest(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "No updates provided", + Type = "invalid_request_error", + Code = "invalid_request" + } + }); + } - // Convert to internal model - var spendUpdates = request.Updates.Select(u => new SpendUpdateItem - { - VirtualKeyId = u.VirtualKeyId, - Amount = u.Amount, - Model = u.Model, - Provider = u.ProviderType.ToString(), - RequestMetadata = u.Metadata - }).ToList(); - - // Get idempotency token from header (optional) - var idempotencyToken = HttpContext.Request.Headers["X-Idempotency-Token"].FirstOrDefault(); - - // Execute batch spend update operation - var result = await _batchSpendUpdateOperation.ExecuteAsync( - spendUpdates, - virtualKeyId, - idempotencyToken, - HttpContext.RequestAborted); - - _logger.LogInformation( - "Started batch spend update operation {OperationId} with {Count} items (Idempotent: {Idempotent})", - result.OperationId, - request.Updates.Count(), - !string.IsNullOrWhiteSpace(idempotencyToken)); - - return Accepted(new BatchOperationStartResponse - { - OperationId = result.OperationId, - OperationType = "spend_update", - TotalItems = request.Updates.Count(), - StatusUrl = $"/v1/batch/operations/{result.OperationId}", - TaskId = result.OperationId, - Message = "Batch operation started. Subscribe to TaskHub with the taskId for real-time updates." - }); + if (request.Updates.Count() > 10000) + { + return BadRequest(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Maximum 10,000 items per batch", + Type = "invalid_request_error", + Code = "invalid_request" + } + }); + } + + // Convert to internal model + var spendUpdates = request.Updates.Select(u => new SpendUpdateItem + { + VirtualKeyId = u.VirtualKeyId, + Amount = u.Amount, + Model = u.Model, + Provider = u.ProviderType.ToString(), + RequestMetadata = u.Metadata + }).ToList(); + + // Get idempotency token from header (optional) + var idempotencyToken = HttpContext.Request.Headers["X-Idempotency-Token"].FirstOrDefault(); + + // Execute batch spend update operation + var result = await _batchSpendUpdateOperation.ExecuteAsync( + spendUpdates, + virtualKeyId, + idempotencyToken, + HttpContext.RequestAborted); + + Logger.LogInformation( + "Started batch spend update operation {OperationId} with {Count} items (Idempotent: {Idempotent})", + result.OperationId, + request.Updates.Count(), + !string.IsNullOrWhiteSpace(idempotencyToken)); + + GatewayOpsMetrics.RecordBatchOperation("spend_update", "accepted", request.Updates.Count()); + return Accepted(new BatchOperationStartResponse + { + OperationId = result.OperationId, + OperationType = "spend_update", + TotalItems = request.Updates.Count(), + StatusUrl = $"/v1/batch/operations/{result.OperationId}", + TaskId = result.OperationId, + Message = "Batch operation started. Subscribe to TaskHub with the taskId for real-time updates." + }); + }, "StartBatchSpendUpdate"); } /// @@ -117,74 +137,93 @@ public async Task StartBatchSpendUpdate([FromBody] BatchSpendUpda [ProducesResponseType(401)] public async Task StartBatchVirtualKeyUpdate([FromBody] BatchVirtualKeyUpdateRequest request) { - var virtualKeyId = GetVirtualKeyId(); - - // Check if user has admin permissions - var virtualKeyInfo = await _virtualKeyService.GetVirtualKeyInfoAsync(virtualKeyId); - bool isAdmin = false; - if (virtualKeyInfo != null && !string.IsNullOrEmpty(virtualKeyInfo.Metadata)) + return await ExecuteAsync(async () => { - try + var virtualKeyId = GetVirtualKeyId(); + + // Check if user has admin permissions + var virtualKeyInfo = await _virtualKeyService.GetVirtualKeyInfoAsync(virtualKeyId); + bool isAdmin = false; + if (virtualKeyInfo != null && !string.IsNullOrEmpty(virtualKeyInfo.Metadata)) { - var metadata = System.Text.Json.JsonSerializer.Deserialize>(virtualKeyInfo.Metadata); - if (metadata != null && metadata.TryGetValue("isAdmin", out var isAdminValue)) + try { - isAdmin = isAdminValue?.ToString()?.ToLower() == "true"; + var metadata = System.Text.Json.JsonSerializer.Deserialize>(virtualKeyInfo.Metadata); + if (metadata != null && metadata.TryGetValue("isAdmin", out var isAdminValue)) + { + isAdmin = isAdminValue?.ToString()?.ToLower() == "true"; + } + } + catch + { + // Invalid metadata format } } - catch + + if (!isAdmin) { - // Invalid metadata format + return Forbid("Admin permissions required for batch virtual key updates"); } - } - - if (!isAdmin) - { - return Forbid("Admin permissions required for batch virtual key updates"); - } - // Validate request - if (request.Updates == null || request.Updates.Count() == 0) - { - return BadRequest(new ErrorResponseDto("No updates provided")); - } + // Validate request + if (request.Updates == null || !request.Updates.Any()) + { + return BadRequest(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "No updates provided", + Type = "invalid_request_error", + Code = "invalid_request" + } + }); + } - if (request.Updates.Count() > 1000) - { - return BadRequest(new ErrorResponseDto("Maximum 1,000 items per batch")); - } + if (request.Updates.Count() > 1000) + { + return BadRequest(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Maximum 1,000 items per batch", + Type = "invalid_request_error", + Code = "invalid_request" + } + }); + } - // Convert to internal model - var keyUpdates = request.Updates.Select(u => new VirtualKeyUpdateItem - { - VirtualKeyId = u.VirtualKeyId, - AllowedModels = u.AllowedModels, - RateLimits = u.RateLimits, - IsEnabled = u.IsEnabled, - ExpiresAt = u.ExpiresAt, - Notes = u.Notes - }).ToList(); - - // Start operation - var result = await _batchVirtualKeyUpdateOperation.ExecuteAsync( - keyUpdates, - virtualKeyId, - HttpContext.RequestAborted); - - _logger.LogInformation( - "Started batch virtual key update operation {OperationId} with {Count} items", - result.OperationId, - request.Updates.Count()); - - return Accepted(new BatchOperationStartResponse - { - OperationId = result.OperationId, - OperationType = "virtual_key_update", - TotalItems = request.Updates.Count(), - StatusUrl = $"/v1/batch/operations/{result.OperationId}", - TaskId = result.OperationId, - Message = "Batch operation started. Subscribe to TaskHub with the taskId for real-time updates." - }); + // Convert to internal model + var keyUpdates = request.Updates.Select(u => new VirtualKeyUpdateItem + { + VirtualKeyId = u.VirtualKeyId, + AllowedModels = u.AllowedModels, + RateLimits = u.RateLimits, + IsEnabled = u.IsEnabled, + ExpiresAt = u.ExpiresAt, + Notes = u.Notes + }).ToList(); + + // Start operation + var result = await _batchVirtualKeyUpdateOperation.ExecuteAsync( + keyUpdates, + virtualKeyId, + HttpContext.RequestAborted); + + Logger.LogInformation( + "Started batch virtual key update operation {OperationId} with {Count} items", + result.OperationId, + request.Updates.Count()); + + return Accepted(new BatchOperationStartResponse + { + OperationId = result.OperationId, + OperationType = "virtual_key_update", + TotalItems = request.Updates.Count(), + StatusUrl = $"/v1/batch/operations/{result.OperationId}", + TaskId = result.OperationId, + Message = "Batch operation started. Subscribe to TaskHub with the taskId for real-time updates." + }); + }, "StartBatchVirtualKeyUpdate"); } /// @@ -198,50 +237,69 @@ public async Task StartBatchVirtualKeyUpdate([FromBody] BatchVirt [ProducesResponseType(401)] public async Task StartBatchWebhookSend([FromBody] BatchWebhookSendRequest request) { - var virtualKeyId = GetVirtualKeyId(); - - // Validate request - if (request.Webhooks == null || request.Webhooks.Count() == 0) + return await ExecuteAsync(async () => { - return BadRequest(new ErrorResponseDto("No webhooks provided")); - } + var virtualKeyId = GetVirtualKeyId(); - if (request.Webhooks.Count() > 5000) - { - return BadRequest(new ErrorResponseDto("Maximum 5,000 webhooks per batch")); - } + // Validate request + if (request.Webhooks == null || !request.Webhooks.Any()) + { + return BadRequest(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "No webhooks provided", + Type = "invalid_request_error", + Code = "invalid_request" + } + }); + } - // Convert to internal model - var webhookSends = request.Webhooks.Select(w => new WebhookSendItem - { - WebhookUrl = w.Url, - VirtualKeyId = virtualKeyId, - EventType = w.EventType, - Payload = w.Payload, - Headers = w.Headers, - Secret = w.Secret - }).ToList(); - - // Start operation - var result = await _batchWebhookSendOperation.ExecuteAsync( - webhookSends, - virtualKeyId, - HttpContext.RequestAborted); - - _logger.LogInformation( - "Started batch webhook send operation {OperationId} with {Count} items", - result.OperationId, - request.Webhooks.Count()); - - return Accepted(new BatchOperationStartResponse - { - OperationId = result.OperationId, - OperationType = "webhook_send", - TotalItems = request.Webhooks.Count(), - StatusUrl = $"/v1/batch/operations/{result.OperationId}", - TaskId = result.OperationId, - Message = "Batch operation started. Subscribe to TaskHub with the taskId for real-time updates." - }); + if (request.Webhooks.Count() > 5000) + { + return BadRequest(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Maximum 5,000 webhooks per batch", + Type = "invalid_request_error", + Code = "invalid_request" + } + }); + } + + // Convert to internal model + var webhookSends = request.Webhooks.Select(w => new WebhookSendItem + { + WebhookUrl = w.Url, + VirtualKeyId = virtualKeyId, + EventType = w.EventType, + Payload = w.Payload, + Headers = w.Headers, + Secret = w.Secret + }).ToList(); + + // Start operation + var result = await _batchWebhookSendOperation.ExecuteAsync( + webhookSends, + virtualKeyId, + HttpContext.RequestAborted); + + Logger.LogInformation( + "Started batch webhook send operation {OperationId} with {Count} items", + result.OperationId, + request.Webhooks.Count()); + + return Accepted(new BatchOperationStartResponse + { + OperationId = result.OperationId, + OperationType = "webhook_send", + TotalItems = request.Webhooks.Count(), + StatusUrl = $"/v1/batch/operations/{result.OperationId}", + TaskId = result.OperationId, + Message = "Batch operation started. Subscribe to TaskHub with the taskId for real-time updates." + }); + }, "StartBatchWebhookSend"); } /// @@ -254,10 +312,20 @@ public async Task StartBatchWebhookSend([FromBody] BatchWebhookSe [ProducesResponseType(404)] public IActionResult GetOperationStatus(string operationId) { + Logger.LogDebug("Getting status for batch operation {OperationId}", operationId); var status = _batchOperationService.GetOperationStatus(operationId); if (status == null) { - return NotFound(new ErrorResponseDto("Operation not found")); + Logger.LogWarning("Batch operation {OperationId} not found", operationId); + return NotFound(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Operation not found", + Type = "not_found_error", + Code = "not_found" + } + }); } return Ok(new BatchOperationStatusResponse @@ -292,21 +360,45 @@ public async Task CancelOperation(string operationId) var status = _batchOperationService.GetOperationStatus(operationId); if (status == null) { - return NotFound(new ErrorResponseDto("Operation not found")); + return NotFound(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Operation not found", + Type = "not_found_error", + Code = "not_found" + } + }); } if (!status.CanCancel) { - return Conflict(new ErrorResponseDto("Operation cannot be cancelled")); + return Conflict(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Operation cannot be cancelled", + Type = "invalid_request_error", + Code = "operation_not_cancellable" + } + }); } var cancelled = await _batchOperationService.CancelBatchOperationAsync(operationId); if (!cancelled) { - return Conflict(new ErrorResponseDto("Failed to cancel operation")); + return Conflict(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Failed to cancel operation", + Type = "invalid_request_error", + Code = "cancellation_failed" + } + }); } - _logger.LogInformation("Cancelled batch operation {OperationId}", operationId); + Logger.LogInformation("Cancelled batch operation {OperationId}", operationId); return NoContent(); } diff --git a/Services/ConduitLLM.Gateway/Controllers/ChatController.Streaming.cs b/Services/ConduitLLM.Gateway/Controllers/ChatController.Streaming.cs new file mode 100644 index 000000000..b964dd56b --- /dev/null +++ b/Services/ConduitLLM.Gateway/Controllers/ChatController.Streaming.cs @@ -0,0 +1,345 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +using ConduitLLM.Core.Models; +using ConduitLLM.Core.Services; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Gateway.Constants; +using ConduitLLM.Gateway.Metrics; +using ConduitLLM.Gateway.Services; +using GatewayOpsMetrics = ConduitLLM.Gateway.Services.GatewayOperationsMetricsService; + +using Microsoft.AspNetCore.Mvc; + +namespace ConduitLLM.Gateway.Controllers +{ + public partial class ChatController + { + private async Task HandleNonStreamingRequestAsync( + ChatCompletionRequest request, + int? virtualKeyId, + Stopwatch operationStopwatch, + CancellationToken cancellationToken) + { + _logger.LogInformation("Handling non-streaming request."); + var response = await _conduit.CreateChatCompletionAsync(request, null, virtualKeyId, cancellationToken); + + if (response.AgenticMetrics?.FunctionCalls != null && response.AgenticMetrics.FunctionCalls.Count > 0) + { + StoreFunctionExecutionResults(response.AgenticMetrics); + } + + GatewayOpsMetrics.RecordLlmOperation("chat_completion", request.Model, "success", operationStopwatch.Elapsed.TotalSeconds); + return Ok(response); + } + + private void StoreFunctionExecutionResults(AgenticExecutionMetrics agenticMetrics) + { + var functionExecutionResults = agenticMetrics.FunctionCalls + .Select(fc => new FunctionExecutionResultForLogging + { + ToolCallId = fc.ToolCallId, + FunctionName = fc.FunctionName, + Status = fc.Success ? "completed" : "failed", + Cost = fc.Cost, + ErrorMessage = fc.ErrorMessage, + FunctionExecutionId = fc.FunctionExecutionId + }) + .ToList(); + + HttpContext.Items[HttpContextKeys.ChatFunctionCalls] = functionExecutionResults; + HttpContext.Items[HttpContextKeys.ChatFunctionCost] = agenticMetrics.TotalFunctionCost; + _logger.LogDebug( + "Stored {Count} function execution results for non-streaming request logging, total cost: {Cost:C}", + functionExecutionResults.Count, agenticMetrics.TotalFunctionCost); + } + + private async Task HandleStreamingRequestAsync( + ChatCompletionRequest request, + int? virtualKeyId, + Stopwatch operationStopwatch, + CancellationToken cancellationToken) + { + _logger.LogInformation("Handling streaming request."); + GatewayOpsMetrics.RecordStreamingRequest(request.Model, "started"); + + var bufferingFeature = HttpContext.Features.Get(); + bufferingFeature?.DisableBuffering(); + + var response = HttpContext.Response; + var sseWriter = response.CreateEnhancedSSEWriter(_jsonSerializerOptions); + + var requestId = Guid.NewGuid().ToString(); + response.Headers["X-Request-ID"] = requestId; + + var modelMapping = await _modelMappingService.GetMappingByModelAliasAsync(request.Model); + var providerId = modelMapping?.ProviderId.ToString() ?? "unknown"; + + _logger.LogInformation("Creating StreamingMetricsCollector for model {Model}, provider {Provider}", LoggingSanitizer.S(request.Model), providerId); + var metricsCollector = new StreamingMetricsCollector(requestId, request.Model, providerId); + + try + { + var state = new StreamingAccumulatorState(); + var firstChunkTime = DateTime.UtcNow; + + await foreach (var chunk in _conduit.StreamChatCompletionAsync( + request, null, virtualKeyId, + CreateToolExecutionCallback(sseWriter, state), + cancellationToken)) + { + state.ChunkCount++; + if (state.ChunkCount == 1) + { + _logger.LogInformation("First chunk received at {Time}ms", (DateTime.UtcNow - firstChunkTime).TotalMilliseconds); + } + + AccumulateContent(chunk, state); + AccumulateToolCalls(chunk, state); + CaptureUsageData(chunk, request, state); + await WriteChunkToStream(chunk, sseWriter, metricsCollector); + await EmitPeriodicMetrics(chunk, sseWriter, metricsCollector); + } + + await StoreStreamingResultsAsync(request, state, metricsCollector, sseWriter, cancellationToken); + + _logger.LogInformation("Streaming completed: {ChunkCount} chunks over {Duration}ms", + state.ChunkCount, (DateTime.UtcNow - firstChunkTime).TotalMilliseconds); + GatewayOpsMetrics.RecordLlmOperation("chat_completion", request.Model, "success", operationStopwatch.Elapsed.TotalSeconds); + GatewayOpsMetrics.RecordStreamingRequest(request.Model, "completed"); + } + catch (Exception streamEx) + { + _logger.LogError(streamEx, "Error in stream processing"); + await sseWriter.WriteErrorEventAsync(streamEx.Message); + GatewayOpsMetrics.RecordLlmOperation("chat_completion", request.Model, "error", operationStopwatch.Elapsed.TotalSeconds); + GatewayOpsMetrics.RecordStreamingRequest(request.Model, "error"); + } + } + + private Func CreateToolExecutionCallback( + EnhancedSSEResponseWriter sseWriter, + StreamingAccumulatorState state) + { + return async (toolEvent, ct) => + { + await sseWriter.WriteToolExecutingEventAsync(toolEvent, ct); + + if (toolEvent.Status == "completed" || toolEvent.Status == "failed") + { + state.FunctionExecutionResults.Add(new FunctionExecutionResultForLogging + { + ToolCallId = toolEvent.ToolCallId, + FunctionName = toolEvent.FunctionName, + Status = toolEvent.Status, + Cost = toolEvent.Cost, + ErrorMessage = toolEvent.ErrorMessage, + FunctionExecutionId = toolEvent.FunctionExecutionId + }); + state.TotalFunctionCost += toolEvent.Cost ?? 0m; + } + }; + } + + private static void AccumulateContent(ChatCompletionChunk chunk, StreamingAccumulatorState state) + { + if (chunk.Choices == null) return; + foreach (var choice in chunk.Choices) + { + if (!string.IsNullOrEmpty(choice.Delta?.Content)) + { + state.ContentAccumulator.Append(choice.Delta.Content); + } + } + } + + private static void AccumulateToolCalls(ChatCompletionChunk chunk, StreamingAccumulatorState state) + { + if (chunk.Choices?.Count > 0 && chunk.Choices[0].Delta?.ToolCalls is { } toolCallDeltas) + { + foreach (var toolCallChunk in toolCallDeltas) + { + if (!state.AccumulatedToolCalls.ContainsKey(toolCallChunk.Index)) + { + state.AccumulatedToolCalls[toolCallChunk.Index] = new ConduitLLM.Core.Models.ToolCall + { + Id = toolCallChunk.Id ?? string.Empty, + Type = toolCallChunk.Type ?? "function", + Function = new ConduitLLM.Core.Models.FunctionCall + { + Name = toolCallChunk.Function?.Name ?? string.Empty, + Arguments = toolCallChunk.Function?.Arguments ?? string.Empty + } + }; + } + else + { + var existing = state.AccumulatedToolCalls[toolCallChunk.Index]; + if (!string.IsNullOrEmpty(toolCallChunk.Function?.Arguments) && existing.Function != null) + { + existing.Function.Arguments += toolCallChunk.Function.Arguments; + } + if (!string.IsNullOrEmpty(toolCallChunk.Function?.Name) && existing.Function != null) + { + existing.Function.Name = toolCallChunk.Function.Name; + } + } + } + } + } + + private static void CaptureUsageData(ChatCompletionChunk chunk, ChatCompletionRequest request, StreamingAccumulatorState state) + { + if (chunk.Usage != null) + { + state.StreamingUsage = chunk.Usage; + state.StreamingModel = chunk.Model ?? request.Model; + } + } + + private async Task WriteChunkToStream( + ChatCompletionChunk chunk, + EnhancedSSEResponseWriter sseWriter, + StreamingMetricsCollector metricsCollector) + { + if (chunk.Choices?.Count > 0 && !string.IsNullOrEmpty(chunk.Choices[0].Delta?.Reasoning)) + { + await sseWriter.WriteReasoningEventAsync(chunk.Choices[0].Delta.Reasoning!); + await sseWriter.WriteContentEventAsync(chunk); + } + else + { + await sseWriter.WriteContentEventAsync(chunk); + } + } + + private async Task EmitPeriodicMetrics( + ChatCompletionChunk chunk, + EnhancedSSEResponseWriter sseWriter, + StreamingMetricsCollector metricsCollector) + { + if (chunk?.Choices?.Count > 0) + { + var hasContent = chunk.Choices.Any(c => !string.IsNullOrEmpty(c.Delta?.Content)); + if (hasContent) + { + if (metricsCollector.GetMetrics().TimeToFirstTokenMs == null) + { + metricsCollector.RecordFirstToken(); + } + else + { + metricsCollector.RecordToken(); + } + } + + if (metricsCollector.ShouldEmitMetrics()) + { + _logger.LogDebug("Emitting streaming metrics"); + await sseWriter.WriteMetricsEventAsync(metricsCollector.GetMetrics()); + } + } + } + + private async Task StoreStreamingResultsAsync( + ChatCompletionRequest request, + StreamingAccumulatorState state, + StreamingMetricsCollector metricsCollector, + EnhancedSSEResponseWriter sseWriter, + CancellationToken cancellationToken) + { + // Store usage data for middleware + if (state.StreamingUsage != null) + { + HttpContext.Items["StreamingUsage"] = state.StreamingUsage; + HttpContext.Items["StreamingModel"] = state.StreamingModel; + HttpContext.Items["UsageIsEstimated"] = false; + } + else if (_usageEstimationService != null && state.ContentAccumulator.Length > 0) + { + _logger.LogWarning("No usage data received from provider for streaming response, estimating usage for model {Model}", LoggingSanitizer.S(request.Model)); + await EstimateStreamingUsageAsync(request, state, cancellationToken); + } + else if (state.ContentAccumulator.Length == 0) + { + _logger.LogWarning("No content accumulated from streaming response, cannot estimate usage"); + } + + // Store tool calls for request logging + if (state.AccumulatedToolCalls.Count > 0) + { + HttpContext.Items["StreamingChatToolCalls"] = state.AccumulatedToolCalls + .OrderBy(kv => kv.Key) + .Select(kv => kv.Value) + .ToList(); + _logger.LogDebug("Stored {Count} accumulated tool calls for request logging", state.AccumulatedToolCalls.Count); + } + + // Store function execution results + if (state.FunctionExecutionResults.Count > 0) + { + HttpContext.Items[HttpContextKeys.ChatFunctionCalls] = state.FunctionExecutionResults; + HttpContext.Items[HttpContextKeys.ChatFunctionCost] = state.TotalFunctionCost; + _logger.LogDebug( + "Stored {Count} function execution results for request logging, total cost: {Cost:C}", + state.FunctionExecutionResults.Count, state.TotalFunctionCost); + } + + // Write final metrics + _logger.LogInformation("StreamingUsage before GetFinalMetrics: {Usage}", + state.StreamingUsage != null ? + $"Prompt={state.StreamingUsage.PromptTokens}, Completion={state.StreamingUsage.CompletionTokens}, Total={state.StreamingUsage.TotalTokens}" : + "null"); + + var finalMetrics = metricsCollector.GetFinalMetrics(state.StreamingUsage); + + _logger.LogInformation("FinalMetrics after GetFinalMetrics: PromptTokens={Prompt}, CompletionTokens={Completion}, TotalTokens={Total}", + finalMetrics.PromptTokens, finalMetrics.CompletionTokens, finalMetrics.TotalTokens); + + await sseWriter.WriteFinalMetricsEventAsync(finalMetrics); + await sseWriter.WriteDoneEventAsync(); + } + + private async Task EstimateStreamingUsageAsync( + ChatCompletionRequest request, + StreamingAccumulatorState state, + CancellationToken cancellationToken) + { + try + { + var estimatedUsage = await _usageEstimationService!.EstimateUsageFromStreamingResponseAsync( + state.StreamingModel ?? request.Model, + request.Messages, + state.ContentAccumulator.ToString(), + cancellationToken); + + HttpContext.Items["StreamingUsage"] = estimatedUsage; + HttpContext.Items["StreamingModel"] = state.StreamingModel ?? request.Model; + HttpContext.Items["UsageIsEstimated"] = true; + + _logger.LogInformation( + "Successfully estimated usage for streaming response: Prompt={PromptTokens}, Completion={CompletionTokens}, Total={TotalTokens}", + estimatedUsage.PromptTokens, estimatedUsage.CompletionTokens, estimatedUsage.TotalTokens); + } + catch (Exception estEx) + { + _logger.LogError(estEx, "Failed to estimate usage for streaming response"); + } + } + + /// + /// Mutable state accumulated during streaming chunk processing. + /// + private sealed class StreamingAccumulatorState + { + public int ChunkCount { get; set; } + public Usage? StreamingUsage { get; set; } + public string? StreamingModel { get; set; } + public StringBuilder ContentAccumulator { get; } = new(); + public Dictionary AccumulatedToolCalls { get; } = new(); + public List FunctionExecutionResults { get; } = new(); + public decimal TotalFunctionCost { get; set; } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Controllers/ChatController.Validation.cs b/Services/ConduitLLM.Gateway/Controllers/ChatController.Validation.cs new file mode 100644 index 000000000..c326073ff --- /dev/null +++ b/Services/ConduitLLM.Gateway/Controllers/ChatController.Validation.cs @@ -0,0 +1,73 @@ +using ConduitLLM.Core.Models; +using ConduitLLM.Core.Extensions; + +using Microsoft.AspNetCore.Mvc; + +namespace ConduitLLM.Gateway.Controllers +{ + public partial class ChatController + { + /// + /// Validates function calling request parameters. + /// + /// Error result if validation fails, null if validation succeeds. + private async Task ValidateFunctionCallRequestAsync( + ChatCompletionRequest request, + CancellationToken cancellationToken) + { + if (_functionConfigRepository == null) + { + _logger.LogError("Function calling requested but IFunctionConfigurationRepository is not available"); + return OpenAIError(500, "Function calling is not configured on this server", "function_calling_unavailable", "server_error"); + } + + try + { + var functionConfigs = await _functionConfigRepository.GetByIdsAsync( + request.FunctionConfigurationIds!, + cancellationToken); + + var missingIds = request.FunctionConfigurationIds! + .Except(functionConfigs.Select(fc => fc.Id)) + .ToList(); + + if (missingIds.Count > 0) + { + _logger.LogWarning("Function calling request includes non-existent function configuration IDs: {MissingIds}", + string.Join(", ", missingIds)); + return OpenAIError(400, $"Function configuration IDs not found: {string.Join(", ", missingIds)}", "invalid_function_configuration_ids"); + } + + var disabledConfigs = functionConfigs.Where(fc => !fc.IsEnabled).ToList(); + if (disabledConfigs.Count > 0) + { + var disabledIds = string.Join(", ", disabledConfigs.Select(fc => fc.Id)); + _logger.LogWarning("Function calling request includes disabled function configurations: {DisabledIds}", disabledIds); + return OpenAIError(400, $"Function configurations are disabled: {disabledIds}", "disabled_function_configurations"); + } + + if (request.MaxAgenticIterations.HasValue) + { + var minIterations = await _globalSettingsCacheService.GetMinAgenticIterationsAsync(); + var maxIterations = await _globalSettingsCacheService.GetMaxAgenticIterationsAsync(); + + if (request.MaxAgenticIterations.Value < minIterations || request.MaxAgenticIterations.Value > maxIterations) + { + _logger.LogWarning("Invalid MaxAgenticIterations value: {Value}, valid range is {Min}-{Max}", + request.MaxAgenticIterations.Value, minIterations, maxIterations); + return OpenAIError(400, $"MaxAgenticIterations must be between {minIterations} and {maxIterations}", "invalid_max_agentic_iterations"); + } + } + + _logger.LogDebug("Function calling validation passed for {Count} function configurations", + functionConfigs.Count); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating function calling request"); + return OpenAIError(500, "Error validating function calling request", "function_validation_error", "server_error"); + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Controllers/ChatController.cs b/Services/ConduitLLM.Gateway/Controllers/ChatController.cs index b7677ab34..3eac31a10 100644 --- a/Services/ConduitLLM.Gateway/Controllers/ChatController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/ChatController.cs @@ -1,20 +1,17 @@ -using System.Text; +using System.Diagnostics; using System.Text.Json; -using ConduitLLM.Configuration; using ConduitLLM.Core; using ConduitLLM.Core.Controllers; -using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; -using ConduitLLM.Core.Services; -using ConduitLLM.Gateway.Constants; -using ConduitLLM.Gateway.Services; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Gateway.Metrics; +using GatewayOpsMetrics = ConduitLLM.Gateway.Services.GatewayOperationsMetricsService; using MassTransit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using ConduitLLM.Gateway.Authorization; namespace ConduitLLM.Gateway.Controllers @@ -24,17 +21,16 @@ namespace ConduitLLM.Gateway.Controllers /// [ApiController] [Route("v1/chat")] - [Authorize] + [Authorize(AuthenticationSchemes = "VirtualKey")] [RequireBalance] [Tags("Chat")] - public class ChatController : EventPublishingControllerBase + public partial class ChatController : GatewayControllerBase { private readonly Conduit _conduit; private readonly ILogger _logger; private readonly ConduitLLM.Configuration.Interfaces.IModelProviderMappingService _modelMappingService; - private readonly IOptions _settings; private readonly JsonSerializerOptions _jsonSerializerOptions; - private readonly IUsageEstimationService? _usageEstimationService; + private readonly ConduitLLM.Core.Interfaces.IUsageEstimationService? _usageEstimationService; private readonly ConduitLLM.Functions.Interfaces.IFunctionConfigurationRepository? _functionConfigRepository; private readonly ConduitLLM.Configuration.Interfaces.IGlobalSettingsCacheService _globalSettingsCacheService; @@ -42,17 +38,15 @@ public ChatController( Conduit conduit, ILogger logger, ConduitLLM.Configuration.Interfaces.IModelProviderMappingService modelMappingService, - IOptions settings, JsonSerializerOptions jsonSerializerOptions, IPublishEndpoint publishEndpoint, ConduitLLM.Configuration.Interfaces.IGlobalSettingsCacheService globalSettingsCacheService, - IUsageEstimationService? usageEstimationService = null, + ConduitLLM.Core.Interfaces.IUsageEstimationService? usageEstimationService = null, ConduitLLM.Functions.Interfaces.IFunctionConfigurationRepository? functionConfigRepository = null) : base(publishEndpoint, logger) { _conduit = conduit ?? throw new ArgumentNullException(nameof(conduit)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _modelMappingService = modelMappingService ?? throw new ArgumentNullException(nameof(modelMappingService)); - _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _jsonSerializerOptions = jsonSerializerOptions ?? throw new ArgumentNullException(nameof(jsonSerializerOptions)); _globalSettingsCacheService = globalSettingsCacheService ?? throw new ArgumentNullException(nameof(globalSettingsCacheService)); _usageEstimationService = usageEstimationService; @@ -62,9 +56,6 @@ public ChatController( /// /// Creates a chat completion. /// - /// The chat completion request. - /// Cancellation token. - /// A chat completion response or a stream of server-sent events. [HttpPost("completions")] [ProducesResponseType(typeof(ChatCompletionResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OpenAIErrorResponse), StatusCodes.Status400BadRequest)] @@ -73,516 +64,85 @@ public async Task CreateChatCompletion( [FromBody] ChatCompletionRequest request, CancellationToken cancellationToken = default) { - _logger.LogInformation("Received /v1/chat/completions request for model: {Model}", request.Model); + using var activity = GatewayRequestMetrics.StartChatCompletionActivity( + request.Model, request.Stream == true); + var operationStopwatch = Stopwatch.StartNew(); + + _logger.LogInformation("Received /v1/chat/completions request for model: {Model}", LoggingSanitizer.S(request.Model)); - // Store streaming flag for middleware HttpContext.Items["IsStreamingRequest"] = request.Stream == true; - - // Get provider info for usage tracking - try - { - var modelMapping = await _modelMappingService.GetMappingByModelAliasAsync(request.Model); - if (modelMapping != null) - { - HttpContext.Items["ProviderId"] = modelMapping.ProviderId; - HttpContext.Items["ProviderType"] = modelMapping.Provider?.ProviderType; - // Store ModelCostId for direct cost lookup (preferred over string matching) - if (modelMapping.ModelProviderTypeAssociation?.ModelCostId != null) - { - HttpContext.Items[HttpContextKeys.ModelCostId] = modelMapping.ModelProviderTypeAssociation.ModelCostId; - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get provider info for model {Model}", request.Model); - } + await PopulateProviderMetadataAsync(request, activity); - // Validate function calling parameters if provided if (request.FunctionConfigurationIds != null && request.FunctionConfigurationIds.Count > 0) { var validationError = await ValidateFunctionCallRequestAsync(request, cancellationToken); if (validationError != null) - { return validationError; - } } - // Apply defaults from GlobalSettings for agentic mode if not explicitly set - if (!request.MaxAgenticIterations.HasValue) - { - request.MaxAgenticIterations = await _globalSettingsCacheService.GetMaxAgenticIterationsAsync(); - } - if (!request.EnableAgenticMode.HasValue) - { - request.EnableAgenticMode = await _globalSettingsCacheService.GetDefaultAgenticModeEnabledAsync(); - } + await ApplyAgenticDefaultsAsync(request); try { - // Extract virtual key ID for function execution billing - int? virtualKeyId = null; - var virtualKeyIdClaim = User.FindFirst("VirtualKeyId")?.Value; - if (!string.IsNullOrEmpty(virtualKeyIdClaim) && int.TryParse(virtualKeyIdClaim, out var keyId)) - { - virtualKeyId = keyId; - } + var virtualKeyId = CurrentVirtualKeyId; - // Non-streaming path if (request.Stream != true) { - _logger.LogInformation("Handling non-streaming request."); - var response = await _conduit.CreateChatCompletionAsync(request, null, virtualKeyId, cancellationToken); - - // Capture function execution results for request logging metadata (non-streaming) - if (response.AgenticMetrics?.FunctionCalls != null && response.AgenticMetrics.FunctionCalls.Count > 0) - { - var functionExecutionResults = response.AgenticMetrics.FunctionCalls - .Select(fc => new FunctionExecutionResultForLogging - { - ToolCallId = fc.ToolCallId, - FunctionName = fc.FunctionName, - Status = fc.Success ? "completed" : "failed", - Cost = fc.Cost, - ErrorMessage = fc.ErrorMessage, - FunctionExecutionId = fc.FunctionExecutionId - }) - .ToList(); - - HttpContext.Items[HttpContextKeys.ChatFunctionCalls] = functionExecutionResults; - HttpContext.Items[HttpContextKeys.ChatFunctionCost] = response.AgenticMetrics.TotalFunctionCost; - _logger.LogDebug( - "Stored {Count} function execution results for non-streaming request logging, total cost: {Cost:C}", - functionExecutionResults.Count, response.AgenticMetrics.TotalFunctionCost); - } - - return Ok(response); + return await HandleNonStreamingRequestAsync(request, virtualKeyId, operationStopwatch, cancellationToken); } else { - _logger.LogInformation("Handling streaming request."); - - // Disable response buffering for true streaming - var bufferingFeature = HttpContext.Features.Get(); - bufferingFeature?.DisableBuffering(); - - // Use enhanced SSE writer for performance metrics support - var response = HttpContext.Response; - var sseWriter = response.CreateEnhancedSSEWriter(_jsonSerializerOptions); - - // Always create metrics collector for token usage tracking - // Token usage is critical for billing and UI display, not just performance metrics - var requestId = Guid.NewGuid().ToString(); - response.Headers["X-Request-ID"] = requestId; - - // Get provider info for metrics from model mapping service - var modelMapping = await _modelMappingService.GetMappingByModelAliasAsync(request.Model); - // Use provider ID for metrics since it's the stable identifier - var providerId = modelMapping?.ProviderId.ToString() ?? "unknown"; - - _logger.LogInformation("Creating StreamingMetricsCollector for model {Model}, provider {Provider}", request.Model, providerId); - var metricsCollector = new StreamingMetricsCollector( - requestId, - request.Model, - providerId); - - try - { - ConduitLLM.Core.Models.Usage? streamingUsage = null; - string? streamingModel = null; - - // Accumulate content for usage estimation if needed - var contentAccumulator = new StringBuilder(); - - // Accumulate tool calls for request logging - var accumulatedToolCalls = new Dictionary(); - - // Accumulate function execution results for request logging metadata - var functionExecutionResults = new List(); - decimal totalFunctionCost = 0m; - - var chunkCount = 0; - var firstChunkTime = DateTime.UtcNow; - - await foreach (var chunk in _conduit.StreamChatCompletionAsync( - request, - null, - virtualKeyId, - async (eventData, ct) => - { - // Write to SSE stream - await sseWriter.WriteToolExecutingEventAsync(eventData, ct); - - // Capture completed/failed events for request logging - // Use reflection to safely extract properties from anonymous object - var eventType = eventData.GetType(); - var statusProp = eventType.GetProperty("status"); - if (statusProp != null) - { - var status = statusProp.GetValue(eventData)?.ToString(); - if (status == "completed" || status == "failed") - { - var result = new FunctionExecutionResultForLogging - { - ToolCallId = eventType.GetProperty("tool_call_id")?.GetValue(eventData)?.ToString(), - FunctionName = eventType.GetProperty("function_name")?.GetValue(eventData)?.ToString(), - Status = status, - Cost = eventType.GetProperty("cost")?.GetValue(eventData) as decimal?, - ErrorMessage = eventType.GetProperty("error_message")?.GetValue(eventData)?.ToString(), - FunctionExecutionId = eventType.GetProperty("function_execution_id")?.GetValue(eventData) as Guid? - }; - functionExecutionResults.Add(result); - totalFunctionCost += result.Cost ?? 0m; - } - } - }, - cancellationToken)) - { - chunkCount++; - if (chunkCount == 1) - { - _logger.LogInformation("First chunk received at {Time}ms", (DateTime.UtcNow - firstChunkTime).TotalMilliseconds); - } - - // Accumulate content from chunks for potential usage estimation - if (chunk.Choices != null) - { - foreach (var choice in chunk.Choices) - { - if (!string.IsNullOrEmpty(choice.Delta?.Content)) - { - contentAccumulator.Append(choice.Delta.Content); - } - } - } - - // Accumulate tool calls from streaming chunks for request logging - if (chunk.Choices?.Count > 0 && chunk.Choices[0].Delta?.ToolCalls is { } toolCallDeltas) - { - foreach (var toolCallChunk in toolCallDeltas) - { - if (!accumulatedToolCalls.ContainsKey(toolCallChunk.Index)) - { - // New tool call - accumulatedToolCalls[toolCallChunk.Index] = new ConduitLLM.Core.Models.ToolCall - { - Id = toolCallChunk.Id ?? string.Empty, - Type = toolCallChunk.Type ?? "function", - Function = new ConduitLLM.Core.Models.FunctionCall - { - Name = toolCallChunk.Function?.Name ?? string.Empty, - Arguments = toolCallChunk.Function?.Arguments ?? string.Empty - } - }; - } - else - { - // Append to existing tool call (arguments come in chunks) - var existing = accumulatedToolCalls[toolCallChunk.Index]; - if (!string.IsNullOrEmpty(toolCallChunk.Function?.Arguments)) - { - if (existing.Function != null) - { - existing.Function.Arguments += toolCallChunk.Function.Arguments; - } - } - if (!string.IsNullOrEmpty(toolCallChunk.Function?.Name) && existing.Function != null) - { - existing.Function.Name = toolCallChunk.Function.Name; - } - } - } - } - - // Check for usage data in chunk (comes in final chunk for OpenAI-compatible APIs) - if (chunk.Usage != null) - { - streamingUsage = chunk.Usage; - streamingModel = chunk.Model ?? request.Model; - _logger.LogDebug("Captured streaming usage data: {Usage}", JsonSerializer.Serialize(streamingUsage)); - } - - // Route chunks to appropriate event types - // 1. Check for reasoning content (Conduit extension) - if (chunk.Choices?.Count > 0 && !string.IsNullOrEmpty(chunk.Choices[0].Delta?.Reasoning)) - { - // Send reasoning as typed "event: reasoning" - // Null-forgiving operator is safe here because we checked IsNullOrEmpty above - await sseWriter.WriteReasoningEventAsync(chunk.Choices[0].Delta.Reasoning!); - - // Also send the chunk as content (for full compatibility) - // Some clients may want the raw chunk with reasoning field - await sseWriter.WriteContentEventAsync(chunk); - } - // 2. Regular content chunk (OpenAI standard) - else - { - await sseWriter.WriteContentEventAsync(chunk); - } - - // โš ๏ธ LEGACY FALLBACK TOKEN COUNTING (INACCURATE) - // ================================================= - // This code attempts to count tokens by tracking chunks, but this is fundamentally flawed: - // - Each chunk typically contains MULTIPLE tokens, not one token - // - RecordToken() is called once per chunk, severely undercounting actual tokens - // - This causes wildly inaccurate tokensPerSecond metrics (e.g., 1.1 when actual is 20+) - // - // WHY THIS EXISTS: - // - Historically, providers didn't return usage data in streaming responses - // - This fallback was meant to provide "some" metrics when usage data was unavailable - // - // CURRENT STATUS: - // - Now using stream_options.include_usage=true to request usage from providers - // - Most OpenAI-compatible providers (OpenAI, Groq, SambaNova, Cerebras) support this - // - When usage data is available, GetFinalMetrics() uses actual token counts (line 240) - // - This fallback only runs when providers don't return usage data - // - // OPTIONS FOR IMPROVEMENT: - // 1. Remove this entirely and rely on provider usage data (requires all providers support it) - // 2. Implement proper tokenization using tiktoken or similar (adds dependency + latency) - // 3. Keep as-is but log warnings when usage data is missing and fallback is used - // 4. Use content length estimation (chars / 4 โ‰ˆ tokens) as a better fallback - // - // RECOMMENDATION: Option 3 - Keep for backward compatibility but warn when used - // ================================================= - if (chunk?.Choices?.Count > 0) - { - var hasContent = chunk.Choices.Any(c => !string.IsNullOrEmpty(c.Delta?.Content)); - if (hasContent) - { - if (metricsCollector.GetMetrics().TimeToFirstTokenMs == null) - { - metricsCollector.RecordFirstToken(); - } - else - { - metricsCollector.RecordToken(); - } - } - - // Emit metrics periodically - if (metricsCollector.ShouldEmitMetrics()) - { - _logger.LogDebug("Emitting streaming metrics"); - await sseWriter.WriteMetricsEventAsync(metricsCollector.GetMetrics()); - } - } - } - - // Store usage data for middleware to process - if (streamingUsage != null) - { - HttpContext.Items["StreamingUsage"] = streamingUsage; - HttpContext.Items["StreamingModel"] = streamingModel; - HttpContext.Items["UsageIsEstimated"] = false; - } - else if (_usageEstimationService != null && contentAccumulator.Length > 0) - { - // No usage data from provider, estimate it to prevent revenue loss - _logger.LogWarning("No usage data received from provider for streaming response, estimating usage for model {Model}", request.Model); - - try - { - var estimatedUsage = await _usageEstimationService.EstimateUsageFromStreamingResponseAsync( - streamingModel ?? request.Model, - request.Messages, - contentAccumulator.ToString(), - cancellationToken); - - HttpContext.Items["StreamingUsage"] = estimatedUsage; - HttpContext.Items["StreamingModel"] = streamingModel ?? request.Model; - HttpContext.Items["UsageIsEstimated"] = true; - - _logger.LogInformation( - "Successfully estimated usage for streaming response: Prompt={PromptTokens}, Completion={CompletionTokens}, Total={TotalTokens}", - estimatedUsage.PromptTokens, estimatedUsage.CompletionTokens, estimatedUsage.TotalTokens); - } - catch (Exception estEx) - { - _logger.LogError(estEx, "Failed to estimate usage for streaming response"); - // Don't throw - we've already sent the response to the user - // The middleware will log this as a billing failure - } - } - else if (contentAccumulator.Length == 0) - { - _logger.LogWarning("No content accumulated from streaming response, cannot estimate usage"); - } - - // Store accumulated tool calls for request logging - if (accumulatedToolCalls.Count > 0) - { - HttpContext.Items["StreamingChatToolCalls"] = accumulatedToolCalls - .OrderBy(kv => kv.Key) - .Select(kv => kv.Value) - .ToList(); - _logger.LogDebug("Stored {Count} accumulated tool calls for request logging", accumulatedToolCalls.Count); - } - - // Store function execution results for request logging metadata - if (functionExecutionResults.Count > 0) - { - HttpContext.Items[HttpContextKeys.ChatFunctionCalls] = functionExecutionResults; - HttpContext.Items[HttpContextKeys.ChatFunctionCost] = totalFunctionCost; - _logger.LogDebug( - "Stored {Count} function execution results for request logging, total cost: {Cost:C}", - functionExecutionResults.Count, totalFunctionCost); - } - - // Always write final metrics with token usage data - _logger.LogInformation("StreamingUsage before GetFinalMetrics: {Usage}", - streamingUsage != null ? - $"Prompt={streamingUsage.PromptTokens}, Completion={streamingUsage.CompletionTokens}, Total={streamingUsage.TotalTokens}" : - "null"); - - // Pass usage data to GetFinalMetrics which will include it in the metrics - var finalMetrics = metricsCollector.GetFinalMetrics(streamingUsage); - - _logger.LogInformation("FinalMetrics after GetFinalMetrics: PromptTokens={Prompt}, CompletionTokens={Completion}, TotalTokens={Total}", - finalMetrics.PromptTokens, finalMetrics.CompletionTokens, finalMetrics.TotalTokens); - - await sseWriter.WriteFinalMetricsEventAsync(finalMetrics); - - // Write [DONE] to signal the end of the stream - await sseWriter.WriteDoneEventAsync(); - - _logger.LogInformation("Streaming completed: {ChunkCount} chunks over {Duration}ms", - chunkCount, (DateTime.UtcNow - firstChunkTime).TotalMilliseconds); - } - catch (Exception streamEx) - { - _logger.LogError(streamEx, "Error in stream processing"); - await sseWriter.WriteErrorEventAsync(streamEx.Message); - } - + await HandleStreamingRequestAsync(request, virtualKeyId, operationStopwatch, cancellationToken); return new EmptyResult(); } } catch (Exception ex) { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("error.type", ex.GetType().Name); _logger.LogError(ex, "Error processing request"); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = ex.Message, - Type = "server_error", - Code = "internal_error" - } - }); + GatewayOpsMetrics.RecordLlmOperation("chat_completion", request.Model, "error", operationStopwatch.Elapsed.TotalSeconds); + return OpenAIError(500, ex.Message, "internal_error", "server_error"); } } - /// - /// Validates function calling request parameters. - /// - /// The chat completion request - /// Cancellation token - /// Error result if validation fails, null if validation succeeds - private async Task ValidateFunctionCallRequestAsync( - ChatCompletionRequest request, - CancellationToken cancellationToken) + private async Task PopulateProviderMetadataAsync(ChatCompletionRequest request, Activity? activity) { - // Check if function repository is available - if (_functionConfigRepository == null) - { - _logger.LogError("Function calling requested but IFunctionConfigurationRepository is not available"); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Function calling is not configured on this server", - Type = "server_error", - Code = "function_calling_unavailable" - } - }); - } - - // Validate function configuration IDs exist and are enabled try { - var functionConfigs = await _functionConfigRepository.GetByIdsAsync( - request.FunctionConfigurationIds!, - cancellationToken); - - // Check if all requested IDs were found - var missingIds = request.FunctionConfigurationIds! - .Except(functionConfigs.Select(fc => fc.Id)) - .ToList(); - - if (missingIds.Count > 0) - { - _logger.LogWarning("Function calling request includes non-existent function configuration IDs: {MissingIds}", - string.Join(", ", missingIds)); - return BadRequest(new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = $"Function configuration IDs not found: {string.Join(", ", missingIds)}", - Type = "invalid_request_error", - Code = "invalid_function_configuration_ids" - } - }); - } - - // Check if all function configurations are enabled - var disabledConfigs = functionConfigs.Where(fc => !fc.IsEnabled).ToList(); - if (disabledConfigs.Count > 0) - { - var disabledIds = string.Join(", ", disabledConfigs.Select(fc => fc.Id)); - _logger.LogWarning("Function calling request includes disabled function configurations: {DisabledIds}", disabledIds); - return BadRequest(new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = $"Function configurations are disabled: {disabledIds}", - Type = "invalid_request_error", - Code = "disabled_function_configurations" - } - }); - } - - // Validate iteration limit if provided (using configurable limits from GlobalSettings) - if (request.MaxAgenticIterations.HasValue) + var modelMapping = await _modelMappingService.GetMappingByModelAliasAsync(request.Model); + if (modelMapping != null) { - var minIterations = await _globalSettingsCacheService.GetMinAgenticIterationsAsync(); - var maxIterations = await _globalSettingsCacheService.GetMaxAgenticIterationsAsync(); + HttpContext.Items["ProviderId"] = modelMapping.ProviderId; + HttpContext.Items["ProviderType"] = modelMapping.Provider?.ProviderType; + activity?.SetTag("gateway.provider_id", modelMapping.ProviderId); + activity?.SetTag("gateway.provider_type", modelMapping.Provider?.ProviderType.ToString()); - if (request.MaxAgenticIterations.Value < minIterations || request.MaxAgenticIterations.Value > maxIterations) + if (modelMapping.ModelProviderTypeAssociation?.ModelCostId != null) { - _logger.LogWarning("Invalid MaxAgenticIterations value: {Value}, valid range is {Min}-{Max}", - request.MaxAgenticIterations.Value, minIterations, maxIterations); - return BadRequest(new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = $"MaxAgenticIterations must be between {minIterations} and {maxIterations}", - Type = "invalid_request_error", - Code = "invalid_max_agentic_iterations" - } - }); + HttpContext.Items[ConduitLLM.Gateway.Constants.HttpContextKeys.ModelCostId] = modelMapping.ModelProviderTypeAssociation.ModelCostId; } } - - _logger.LogDebug("Function calling validation passed for {Count} function configurations", - functionConfigs.Count); - return null; // Validation passed } catch (Exception ex) { - _logger.LogError(ex, "Error validating function calling request"); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Error validating function calling request", - Type = "server_error", - Code = "function_validation_error" - } - }); + _logger.LogWarning(ex, "Failed to get provider info for model {Model}", LoggingSanitizer.S(request.Model)); } } + + private async Task ApplyAgenticDefaultsAsync(ChatCompletionRequest request) + { + if (!request.MaxAgenticIterations.HasValue) + { + request.MaxAgenticIterations = await _globalSettingsCacheService.GetMaxAgenticIterationsAsync(); + } + if (!request.EnableAgenticMode.HasValue) + { + request.EnableAgenticMode = await _globalSettingsCacheService.GetDefaultAgenticModeEnabledAsync(); + } + } + } /// @@ -592,34 +152,11 @@ public async Task CreateChatCompletion( /// public class FunctionExecutionResultForLogging { - /// - /// The tool call ID from the LLM response - /// public string? ToolCallId { get; set; } - - /// - /// Name of the function that was executed - /// public string? FunctionName { get; set; } - - /// - /// Execution status: "completed" or "failed" - /// public string? Status { get; set; } - - /// - /// Cost of the function execution - /// public decimal? Cost { get; set; } - - /// - /// Error message if the function failed - /// public string? ErrorMessage { get; set; } - - /// - /// ID of the FunctionExecution record for audit/drill-down - /// public Guid? FunctionExecutionId { get; set; } } } diff --git a/Services/ConduitLLM.Gateway/Controllers/CompletionsController.cs b/Services/ConduitLLM.Gateway/Controllers/CompletionsController.cs index 6e2312226..9416dc4d1 100644 --- a/Services/ConduitLLM.Gateway/Controllers/CompletionsController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/CompletionsController.cs @@ -1,3 +1,6 @@ +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Models; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,13 +13,11 @@ namespace ConduitLLM.Gateway.Controllers [Route("v1")] [Authorize(AuthenticationSchemes = "VirtualKey")] [Tags("Completions")] - public class CompletionsController : ControllerBase + public class CompletionsController : GatewayControllerBase { - private readonly ILogger _logger; - public CompletionsController(ILogger logger) + : base(logger) { - _logger = logger; } /// @@ -24,13 +25,18 @@ public CompletionsController(ILogger logger) /// /// A 501 Not Implemented response directing users to use /chat/completions. [HttpPost("completions")] - [ProducesResponseType(typeof(object), StatusCodes.Status501NotImplemented)] + [ProducesResponseType(typeof(OpenAIErrorResponse), StatusCodes.Status501NotImplemented)] public IActionResult CreateCompletion() { - _logger.LogInformation("Legacy /completions endpoint called."); - return StatusCode(501, new + Logger.LogInformation("Legacy /completions endpoint called."); + return StatusCode(501, new OpenAIErrorResponse { - error = "The /completions endpoint is not implemented. Please use /chat/completions." + Error = new OpenAIError + { + Message = "The /completions endpoint is not implemented. Please use /chat/completions.", + Type = "invalid_request_error", + Code = "not_implemented" + } }); } } diff --git a/Services/ConduitLLM.Gateway/Controllers/DiscoveryController.cs b/Services/ConduitLLM.Gateway/Controllers/DiscoveryController.cs index 46d4e49af..4f1a8ef98 100644 --- a/Services/ConduitLLM.Gateway/Controllers/DiscoveryController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/DiscoveryController.cs @@ -1,4 +1,7 @@ +using System.Text.Json; using ConduitLLM.Configuration; +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -15,13 +18,12 @@ namespace ConduitLLM.Gateway.Controllers [ApiController] [Route("v1/discovery")] [Authorize] - public class DiscoveryController : ControllerBase + public class DiscoveryController : GatewayControllerBase { private readonly IDbContextFactory _dbContextFactory; private readonly IModelCapabilityService _modelCapabilityService; private readonly IVirtualKeyService _virtualKeyService; private readonly IDiscoveryCacheService _discoveryCacheService; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -32,12 +34,12 @@ public DiscoveryController( IVirtualKeyService virtualKeyService, IDiscoveryCacheService discoveryCacheService, ILogger logger) + : base(logger) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); _modelCapabilityService = modelCapabilityService ?? throw new ArgumentNullException(nameof(modelCapabilityService)); _virtualKeyService = virtualKeyService ?? throw new ArgumentNullException(nameof(virtualKeyService)); _discoveryCacheService = discoveryCacheService ?? throw new ArgumentNullException(nameof(discoveryCacheService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -46,32 +48,32 @@ public DiscoveryController( /// Optional capability filter (e.g., "video_generation", "vision") /// List of models with their capabilities. [HttpGet("models")] - public async Task GetModels([FromQuery] string? capability = null) + public Task GetModels([FromQuery] string? capability = null) { - try + return ExecuteAsync(async () => { // Get virtual key from user claims var virtualKeyValue = HttpContext.User.FindFirst("VirtualKey")?.Value; if (string.IsNullOrEmpty(virtualKeyValue)) { - return Unauthorized(new ErrorResponseDto("Virtual key not found")); + return OpenAIError(401, "Virtual key not found", "unauthorized"); } // Validate virtual key is active var virtualKey = await _virtualKeyService.ValidateVirtualKeyAsync(virtualKeyValue); if (virtualKey == null) { - return Unauthorized(new ErrorResponseDto("Invalid virtual key")); + return OpenAIError(401, "Invalid virtual key", "unauthorized"); } // Build cache key based on capability filter var cacheKey = DiscoveryCacheService.BuildCacheKey(capability); - + // Try to get from cache first var cachedResult = await _discoveryCacheService.GetDiscoveryResultsAsync(cacheKey); if (cachedResult != null) { - _logger.LogDebug("Returning cached discovery results for capability: {Capability}", capability ?? "all"); + Logger.LogDebug("Returning cached discovery results for capability: {Capability}", LoggingSanitizer.S(capability ?? "all")); return Ok(new { data = cachedResult.Data, @@ -80,26 +82,28 @@ public async Task GetModels([FromQuery] string? capability = null } using var context = await _dbContextFactory.CreateDbContextAsync(); - + // Get all enabled model mappings with their related data var modelMappings = await context.ModelProviderMappings .Include(m => m.Provider) .Include(m => m.ModelProviderTypeAssociation) .ThenInclude(mpta => mpta.Model) .ThenInclude(m => m.Series) + .AsNoTracking() .Where(m => m.IsEnabled && m.Provider != null && m.Provider.IsEnabled) .ToListAsync(); - - _logger.LogInformation($"Found {modelMappings.Count} enabled model mappings"); - var models = new List(); + Logger.LogDebug("Found {Count} enabled model mappings for discovery (capability filter: {Capability})", + modelMappings.Count, LoggingSanitizer.S(capability ?? "all")); + + var models = new List(); foreach (var mapping in modelMappings) { // Skip if model is missing if (mapping.ModelProviderTypeAssociation?.Model == null) { - _logger.LogWarning("Model mapping {ModelAlias} has no model data", mapping.ModelAlias); + Logger.LogWarning("Model mapping {ModelAlias} has no model data", LoggingSanitizer.S(mapping.ModelAlias)); continue; } @@ -131,34 +135,19 @@ public async Task GetModels([FromQuery] string? capability = null // Currently commented out as we're moving to full parameter pass-through // and ApiParameters field is being deprecated. Parameters should be derived // from the UI-focused Parameters JSON object instead. - /* - // Parse parameters from mapping (priority) or series (fallback) - string[]? supportedParameters = null; - var parametersJson = mapping.ApiParameters ?? mapping.ModelProviderTypeAssociation?.Model?.Series?.Parameters; - if (!string.IsNullOrEmpty(parametersJson)) - { - try - { - supportedParameters = System.Text.Json.JsonSerializer.Deserialize(parametersJson); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to parse parameters for model {ModelAlias}", mapping.ModelAlias); - } - } - */ // Use overrides from association first, then fall back to model defaults var maxInputTokens = mapping.ModelProviderTypeAssociation.MaxInputTokens ?? caps.MaxInputTokens ?? 0; var maxOutputTokens = mapping.ModelProviderTypeAssociation.MaxOutputTokens ?? caps.MaxOutputTokens ?? 0; - models.Add(new + // Serialize to JsonElement for cache-safe storage (anonymous objects can't round-trip through JSON deserialization) + models.Add(JsonSerializer.SerializeToElement(new { // Identity id = mapping.ModelAlias, provider = mapping.Provider?.ProviderType.ToString().ToLowerInvariant(), display_name = mapping.ModelAlias, - + // Metadata description = mapping.ModelProviderTypeAssociation?.Model?.Description ?? string.Empty, model_card_url = mapping.ModelProviderTypeAssociation?.Model?.ModelCardUrl ?? string.Empty, @@ -166,13 +155,10 @@ public async Task GetModels([FromQuery] string? capability = null max_input_tokens = maxInputTokens, max_output_tokens = maxOutputTokens, tokenizer_type = caps.TokenizerType.ToString().ToLowerInvariant(), - - // Configuration - // supported_parameters = supportedParameters ?? Array.Empty(), // TODO: Re-implement based on Parameters field - + // UI Parameters from Model or Series parameters = mapping.ModelProviderTypeAssociation?.Model?.ModelParameters ?? mapping.ModelProviderTypeAssociation?.Model?.Series?.Parameters ?? "{}", - + // Capabilities (nested object as expected by SDK) capabilities = new { @@ -189,14 +175,7 @@ public async Task GetModels([FromQuery] string? capability = null max_tokens = maxInputTokens + maxOutputTokens, max_output_tokens = maxOutputTokens } - - // TODO: Future additions to consider: - // - context_window (from capabilities or series metadata) - // - training_cutoff date - // - pricing_tier or cost information - // - rate_limits - // - model_version - }); + })); } // Cache the results for future requests @@ -206,23 +185,20 @@ public async Task GetModels([FromQuery] string? capability = null Count = models.Count, CapabilityFilter = capability }; - + await _discoveryCacheService.SetDiscoveryResultsAsync(cacheKey, discoveryResult); - - _logger.LogInformation("Cached discovery results for capability: {Capability} with {Count} models", - capability ?? "all", models.Count); + + Logger.LogInformation("Cached discovery results for capability: {Capability} with {Count} models", + LoggingSanitizer.S(capability ?? "all"), models.Count); return Ok(new { data = models, count = models.Count }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving model discovery information"); - return StatusCode(500, new ErrorResponseDto("Failed to retrieve model discovery information")); - } + }, + "GetModels", + capability); } /// @@ -232,7 +208,7 @@ public async Task GetModels([FromQuery] string? capability = null [HttpGet("capabilities")] public Task GetCapabilities() { - try + return ExecuteAsync(async () => { // Return all known capabilities var capabilities = new[] @@ -248,16 +224,12 @@ public Task GetCapabilities() "json_mode" }; - return Task.FromResult(Ok(new + return await Task.FromResult(Ok(new { capabilities = capabilities })); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving capabilities list"); - return Task.FromResult(StatusCode(500, new ErrorResponseDto("Failed to retrieve capabilities"))); - } + }, + "GetCapabilities"); } /// @@ -265,37 +237,33 @@ public Task GetCapabilities() /// /// The model alias or identifier to get parameters for /// JSON object containing UI parameter definitions for the model. - /// - /// This endpoint returns the UI-focused parameter definitions from the ModelSeries.Parameters field, - /// which contains JSON objects defining sliders, selects, textareas, and other UI controls. - /// This allows clients to dynamically generate appropriate UI controls without Admin API access. - /// [HttpGet("models/{model}/parameters")] - public async Task GetModelParameters(string model) + public Task GetModelParameters(string model) { - try + return ExecuteAsync(async () => { // Get virtual key from user claims var virtualKeyValue = HttpContext.User.FindFirst("VirtualKey")?.Value; if (string.IsNullOrEmpty(virtualKeyValue)) { - return Unauthorized(new ErrorResponseDto("Virtual key not found")); + return OpenAIError(401, "Virtual key not found", "unauthorized"); } // Validate virtual key is active var virtualKey = await _virtualKeyService.ValidateVirtualKeyAsync(virtualKeyValue); if (virtualKey == null) { - return Unauthorized(new ErrorResponseDto("Invalid virtual key")); + return OpenAIError(401, "Invalid virtual key", "unauthorized"); } using var context = await _dbContextFactory.CreateDbContextAsync(); - + // Find the model mapping by alias var modelMapping = await context.ModelProviderMappings .Include(m => m.ModelProviderTypeAssociation) .ThenInclude(mpta => mpta.Model) .ThenInclude(m => m!.Series) + .AsNoTracking() .Where(m => m.ModelAlias == model && m.IsEnabled) .FirstOrDefaultAsync(); @@ -308,6 +276,7 @@ public async Task GetModelParameters(string model) .Include(m => m.ModelProviderTypeAssociation) .ThenInclude(mpta => mpta.Model) .ThenInclude(m => m!.Series) + .AsNoTracking() .Where(m => m.ModelProviderTypeAssociation != null && m.ModelProviderTypeAssociation.ModelId == modelId && m.IsEnabled) .FirstOrDefaultAsync(); } @@ -315,14 +284,14 @@ public async Task GetModelParameters(string model) if (modelMapping?.ModelProviderTypeAssociation?.Model == null) { - return NotFound(new ErrorResponseDto($"Model '{model}' not found or has no parameter information")); + return OpenAIError(404, $"Model '{model}' not found or has no parameter information", "model_not_found"); } // Parse the Parameters JSON - check model-specific parameters first, then fall back to series object? parameters = null; - var parametersJson = modelMapping.ModelProviderTypeAssociation.Model.ModelParameters + var parametersJson = modelMapping.ModelProviderTypeAssociation.Model.ModelParameters ?? modelMapping.ModelProviderTypeAssociation.Model.Series?.Parameters; - + if (!string.IsNullOrEmpty(parametersJson)) { try @@ -331,7 +300,7 @@ public async Task GetModelParameters(string model) } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to parse parameters for model {Model}", model); + Logger.LogWarning(ex, "Failed to parse parameters for model {Model}", LoggingSanitizer.S(model)); parameters = new { }; } } @@ -343,12 +312,9 @@ public async Task GetModelParameters(string model) series_name = modelMapping.ModelProviderTypeAssociation.Model.Series?.Name ?? string.Empty, parameters = parameters ?? new { } }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving model parameters for {Model}", model); - return StatusCode(500, new ErrorResponseDto("Failed to retrieve model parameters")); - } + }, + "GetModelParameters", + model); } /// @@ -358,24 +324,24 @@ public async Task GetModelParameters(string model) /// Optional provider type filter (e.g., "Exa", "Perplexity") /// List of available function configurations [HttpGet("functions")] - public async Task GetFunctions( + public Task GetFunctions( [FromQuery] string? purpose = null, [FromQuery] string? providerType = null) { - try + return ExecuteAsync(async () => { // Get virtual key from user claims var virtualKeyValue = HttpContext.User.FindFirst("VirtualKey")?.Value; if (string.IsNullOrEmpty(virtualKeyValue)) { - return Unauthorized(new ErrorResponseDto("Virtual key not found")); + return OpenAIError(401, "Virtual key not found", "unauthorized"); } // Validate virtual key is active var virtualKey = await _virtualKeyService.ValidateVirtualKeyAsync(virtualKeyValue); if (virtualKey == null) { - return Unauthorized(new ErrorResponseDto("Invalid virtual key")); + return OpenAIError(401, "Invalid virtual key", "unauthorized"); } // Build cache key based on filters @@ -385,7 +351,7 @@ public async Task GetFunctions( var cachedResult = await _discoveryCacheService.GetDiscoveryResultsAsync(cacheKey); if (cachedResult != null) { - _logger.LogDebug("Returning cached function discovery results"); + Logger.LogDebug("Returning cached function discovery results"); return Ok(cachedResult.Data); } @@ -412,7 +378,7 @@ public async Task GetFunctions( } } - var configurations = await query.ToListAsync(); + var configurations = await query.AsNoTracking().ToListAsync(); var result = new ConduitLLM.Functions.DTOs.FunctionDiscoveryResponse { @@ -433,22 +399,18 @@ public async Task GetFunctions( // Cache the results var discoveryResult = new DiscoveryModelsResult { - Data = new List { result }, + Data = new List { JsonSerializer.SerializeToElement(result) }, Count = result.Count, CapabilityFilter = purpose }; await _discoveryCacheService.SetDiscoveryResultsAsync(cacheKey, discoveryResult); - _logger.LogInformation("Cached function discovery results with {Count} functions", result.Count); + Logger.LogInformation("Cached function discovery results with {Count} functions", result.Count); return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving function discovery information"); - return StatusCode(500, new ErrorResponseDto("Failed to retrieve function discovery information")); - } + }, + "GetFunctions"); } /// @@ -458,22 +420,22 @@ public async Task GetFunctions( /// The function configuration ID /// JSON schema defining required and optional parameters [HttpGet("functions/{functionConfigurationId}/parameters")] - public async Task GetFunctionParameters(int functionConfigurationId) + public Task GetFunctionParameters(int functionConfigurationId) { - try + return ExecuteAsync(async () => { // Get virtual key from user claims var virtualKeyValue = HttpContext.User.FindFirst("VirtualKey")?.Value; if (string.IsNullOrEmpty(virtualKeyValue)) { - return Unauthorized(new ErrorResponseDto("Virtual key not found")); + return OpenAIError(401, "Virtual key not found", "unauthorized"); } // Validate virtual key is active var virtualKey = await _virtualKeyService.ValidateVirtualKeyAsync(virtualKeyValue); if (virtualKey == null) { - return Unauthorized(new ErrorResponseDto("Invalid virtual key")); + return OpenAIError(401, "Invalid virtual key", "unauthorized"); } // Build cache key @@ -483,7 +445,7 @@ public async Task GetFunctionParameters(int functionConfiguration var cachedResult = await _discoveryCacheService.GetDiscoveryResultsAsync(cacheKey); if (cachedResult != null) { - _logger.LogDebug("Returning cached function parameter schema for config {ConfigId}", functionConfigurationId); + Logger.LogDebug("Returning cached function parameter schema for config {ConfigId}", functionConfigurationId); return Ok(cachedResult.Data); } @@ -491,12 +453,13 @@ public async Task GetFunctionParameters(int functionConfiguration // Find the function configuration var configuration = await context.FunctionConfigurations + .AsNoTracking() .Where(fc => fc.Id == functionConfigurationId && fc.IsEnabled) .FirstOrDefaultAsync(); if (configuration == null) { - return NotFound(new ErrorResponseDto($"Function configuration {functionConfigurationId} not found or is disabled")); + return OpenAIError(404, $"Function configuration {functionConfigurationId} not found or is disabled", "not_found"); } // Parse the parameter schema @@ -518,7 +481,7 @@ public async Task GetFunctionParameters(int functionConfiguration } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to parse parameter schema for function config {ConfigId}", functionConfigurationId); + Logger.LogWarning(ex, "Failed to parse parameter schema for function config {ConfigId}", functionConfigurationId); parameterSchema = new { }; } } @@ -536,24 +499,18 @@ public async Task GetFunctionParameters(int functionConfiguration // Cache the results var discoveryResult = new DiscoveryModelsResult { - Data = new List { result }, + Data = new List { JsonSerializer.SerializeToElement(result) }, Count = 1 }; await _discoveryCacheService.SetDiscoveryResultsAsync(cacheKey, discoveryResult); - _logger.LogInformation("Cached function parameter schema for config {ConfigId}", functionConfigurationId); + Logger.LogInformation("Cached function parameter schema for config {ConfigId}", functionConfigurationId); return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving function parameters for config {ConfigId}", functionConfigurationId); - return StatusCode(500, new ErrorResponseDto("Failed to retrieve function parameters")); - } + }, + "GetFunctionParameters", + functionConfigurationId); } } - - // TODO: Add audit logging for discovery requests to track which virtual keys are querying model information - // TODO: Consider adding pricing information to model discovery responses once pricing data is available in the system } diff --git a/Services/ConduitLLM.Gateway/Controllers/DownloadsController.cs b/Services/ConduitLLM.Gateway/Controllers/DownloadsController.cs index b6a5f5e47..bcd2451f3 100644 --- a/Services/ConduitLLM.Gateway/Controllers/DownloadsController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/DownloadsController.cs @@ -1,3 +1,5 @@ +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,10 +14,9 @@ namespace ConduitLLM.Gateway.Controllers [ApiController] [Route("v1/downloads")] [Authorize] - public class DownloadsController : ControllerBase + public class DownloadsController : GatewayControllerBase { private readonly IFileRetrievalService _fileRetrievalService; - private readonly ILogger _logger; private readonly IMediaRecordRepository _mediaRecordRepository; /// @@ -25,9 +26,9 @@ public DownloadsController( IFileRetrievalService fileRetrievalService, ILogger logger, IMediaRecordRepository mediaRecordRepository) + : base(logger) { _fileRetrievalService = fileRetrievalService ?? throw new ArgumentNullException(nameof(fileRetrievalService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _mediaRecordRepository = mediaRecordRepository ?? throw new ArgumentNullException(nameof(mediaRecordRepository)); } @@ -38,12 +39,15 @@ public DownloadsController( /// Whether to display inline (true) or force download (false). /// The file content. [HttpGet("{**fileId}")] - public async Task DownloadFile(string fileId, [FromQuery] bool inline = false) + public Task DownloadFile(string fileId, [FromQuery] bool inline = false) { - try + return ExecuteAsync(async () => { - // Validate ownership var virtualKeyId = GetVirtualKeyId(); + Logger.LogDebug("File download requested by Virtual Key {VirtualKeyId}: {FileId}, inline: {Inline}", + virtualKeyId, LoggingSanitizer.S(fileId), inline); + + // Validate ownership if (!await ValidateFileOwnership(fileId, virtualKeyId)) { return NotFound(new ErrorResponseDto(new ErrorDetailsDto("File not found", "not_found"))); @@ -72,17 +76,12 @@ public async Task DownloadFile(string fileId, [FromQuery] bool in // Return file with range processing support return File( - result.ContentStream, + result.ContentStream, result.Metadata.ContentType, result.Metadata.FileName, enableRangeProcessing: result.Metadata.SupportsRangeRequests); } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error downloading file {FileId}", fileId); - return StatusCode(500, new ErrorResponseDto(new ErrorDetailsDto("An error occurred while downloading the file", "server_error"))); - } + }, nameof(DownloadFile), fileId); } /// @@ -91,9 +90,9 @@ public async Task DownloadFile(string fileId, [FromQuery] bool in /// The file identifier. /// File metadata. [HttpGet("metadata/{**fileId}")] - public async Task GetFileMetadata(string fileId) + public Task GetFileMetadata(string fileId) { - try + return ExecuteAsync(async () => { // Validate ownership var virtualKeyId = GetVirtualKeyId(); @@ -120,12 +119,7 @@ public async Task GetFileMetadata(string fileId) supports_range_requests = metadata.SupportsRangeRequests, additional_metadata = metadata.AdditionalMetadata }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting metadata for file {FileId}", fileId); - return StatusCode(500, new ErrorResponseDto(new ErrorDetailsDto("An error occurred while retrieving file metadata", "server_error"))); - } + }, nameof(GetFileMetadata), fileId); } /// @@ -134,17 +128,20 @@ public async Task GetFileMetadata(string fileId) /// The URL generation request. /// A temporary download URL. [HttpPost("generate-url")] - public async Task GenerateDownloadUrl([FromBody] GenerateUrlRequest request) + public Task GenerateDownloadUrl([FromBody] GenerateUrlRequest request) { - try + return ExecuteAsync(async () => { if (string.IsNullOrWhiteSpace(request.FileId)) { return BadRequest(new ErrorResponseDto(new ErrorDetailsDto("File ID is required", "invalid_request_error"))); } - // Validate ownership var virtualKeyId = GetVirtualKeyId(); + Logger.LogInformation("Download URL generation requested by Virtual Key {VirtualKeyId} for {FileId}, expiration: {ExpirationMinutes}m", + virtualKeyId, LoggingSanitizer.S(request.FileId), request.ExpirationMinutes ?? 60); + + // Validate ownership if (!await ValidateFileOwnership(request.FileId, virtualKeyId)) { return NotFound(new ErrorResponseDto(new ErrorDetailsDto("File not found", "not_found"))); @@ -170,12 +167,7 @@ public async Task GenerateDownloadUrl([FromBody] GenerateUrlReque expires_at = DateTime.UtcNow.Add(expiration), expiration_minutes = expirationMinutes }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating download URL for file {FileId}", request.FileId); - return StatusCode(500, new ErrorResponseDto(new ErrorDetailsDto("An error occurred while generating download URL", "server_error"))); - } + }, nameof(GenerateDownloadUrl), request.FileId); } /// @@ -184,9 +176,9 @@ public async Task GenerateDownloadUrl([FromBody] GenerateUrlReque /// The file identifier. /// 200 OK if exists, 404 if not. [HttpHead("{**fileId}")] - public async Task CheckFileExists(string fileId) + public Task CheckFileExists(string fileId) { - try + return ExecuteAsync(async () => { // Validate ownership var virtualKeyId = GetVirtualKeyId(); @@ -213,12 +205,7 @@ public async Task CheckFileExists(string fileId) } return Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking existence of file {FileId}", fileId); - return StatusCode(500); - } + }, nameof(CheckFileExists), fileId); } /// @@ -241,7 +228,7 @@ private async Task ValidateFileOwnership(string fileId, int virtualKeyId) { if (virtualKeyId <= 0) { - _logger.LogWarning("Invalid Virtual Key ID: {VirtualKeyId}", virtualKeyId); + Logger.LogWarning("Invalid Virtual Key ID: {VirtualKeyId}", virtualKeyId); return false; } @@ -249,23 +236,23 @@ private async Task ValidateFileOwnership(string fileId, int virtualKeyId) if (fileId.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || fileId.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("URL-based file access attempted by Virtual Key {VirtualKeyId}: {FileId}", + Logger.LogWarning("URL-based file access attempted by Virtual Key {VirtualKeyId}: {FileId}", virtualKeyId, fileId); return false; } // Check if the file exists in our media records var mediaRecord = await _mediaRecordRepository.GetByStorageKeyAsync(fileId); - + if (mediaRecord == null) { - _logger.LogWarning("Media record not found for storage key: {StorageKey}", fileId); + Logger.LogWarning("Media record not found for storage key: {StorageKey}", fileId); return false; } if (mediaRecord.VirtualKeyId != virtualKeyId) { - _logger.LogWarning("Virtual Key {RequestingKeyId} attempted to access file belonging to Virtual Key {OwnerKeyId}", + Logger.LogWarning("Virtual Key {RequestingKeyId} attempted to access file belonging to Virtual Key {OwnerKeyId}", virtualKeyId, mediaRecord.VirtualKeyId); return false; } diff --git a/Services/ConduitLLM.Gateway/Controllers/EmbeddingsController.cs b/Services/ConduitLLM.Gateway/Controllers/EmbeddingsController.cs index 5320c0b1d..e2c1bfb63 100644 --- a/Services/ConduitLLM.Gateway/Controllers/EmbeddingsController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/EmbeddingsController.cs @@ -1,7 +1,10 @@ +using System.Diagnostics; using ConduitLLM.Core; using ConduitLLM.Core.Controllers; -using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Models; +using ConduitLLM.Gateway.Metrics; +using GatewayOpsMetrics = ConduitLLM.Gateway.Services.GatewayOperationsMetricsService; using MassTransit; @@ -19,10 +22,9 @@ namespace ConduitLLM.Gateway.Controllers [Authorize(AuthenticationSchemes = "VirtualKey")] [RequireBalance] [Tags("Embeddings")] - public class EmbeddingsController : EventPublishingControllerBase + public class EmbeddingsController : GatewayControllerBase { private readonly Conduit _conduit; - private readonly ILogger _logger; private readonly ConduitLLM.Configuration.Interfaces.IModelProviderMappingService _modelMappingService; public EmbeddingsController( @@ -32,7 +34,6 @@ public EmbeddingsController( IPublishEndpoint publishEndpoint) : base(publishEndpoint, logger) { _conduit = conduit ?? throw new ArgumentNullException(nameof(conduit)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _modelMappingService = modelMappingService ?? throw new ArgumentNullException(nameof(modelMappingService)); } @@ -63,43 +64,38 @@ public async Task CreateEmbedding( }); } - try - { - _logger.LogInformation("Processing embeddings request for model: {Model}", request.Model); - - // Get provider info for usage tracking - try + using var activity = GatewayRequestMetrics.StartEmbeddingsActivity(request.Model); + var sw = Stopwatch.StartNew(); + + return await ExecuteAsync( + async () => { - var modelMapping = await _modelMappingService.GetMappingByModelAliasAsync(request.Model); - if (modelMapping != null) + Logger.LogInformation("Processing embeddings request for model: {Model}", LoggingSanitizer.S(request.Model)); + + // Get provider info for usage tracking + try { - HttpContext.Items["ProviderId"] = modelMapping.ProviderId; - HttpContext.Items["ProviderType"] = modelMapping.Provider?.ProviderType; + var modelMapping = await _modelMappingService.GetMappingByModelAliasAsync(request.Model); + if (modelMapping != null) + { + HttpContext.Items["ProviderId"] = modelMapping.ProviderId; + HttpContext.Items["ProviderType"] = modelMapping.Provider?.ProviderType; + } } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get provider info for model {Model}", request.Model); - } - - // Get the client for the specified model and create embeddings - var client = _conduit.GetClient(request.Model); - var response = await client.CreateEmbeddingAsync(request, cancellationToken: cancellationToken); - return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing embeddings request for model: {Model}", request.Model); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError + catch (Exception ex) { - Message = ex.Message, - Type = "server_error", - Code = "internal_error" + Logger.LogWarning(ex, "Failed to get provider info for model {Model}", request.Model); } - }); - } + + // Get the client for the specified model and create embeddings + var client = await _conduit.GetClientAsync(request.Model, cancellationToken); + var result = await client.CreateEmbeddingAsync(request, cancellationToken: cancellationToken); + GatewayOpsMetrics.RecordLlmOperation("embedding", request.Model, "success", sw.Elapsed.TotalSeconds); + return result; + }, + result => Ok(result), + "CreateEmbedding", + request.Model); } } } diff --git a/Services/ConduitLLM.Gateway/Controllers/FunctionsController.cs b/Services/ConduitLLM.Gateway/Controllers/FunctionsController.cs index 7be7681b3..4f14ac85e 100644 --- a/Services/ConduitLLM.Gateway/Controllers/FunctionsController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/FunctionsController.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using ConduitLLM.Configuration.DTOs; using ConduitLLM.Core.Controllers; using ConduitLLM.Functions.Interfaces; using ConduitLLM.Functions.Enums; @@ -18,7 +17,7 @@ namespace ConduitLLM.Gateway.Controllers; [Authorize] [RequireBalance] [Tags("Functions")] -public class FunctionsController : EventPublishingControllerBase +public class FunctionsController : GatewayControllerBase { private readonly IFunctionExecutionService _executionService; private readonly IFunctionConfigurationRepository _configurationRepository; @@ -61,7 +60,7 @@ public async Task ExecuteFunction( { if (request == null) { - return BadRequest(new ErrorResponseDto("Request body is required")); + return OpenAIError(400, "Request body is required", "invalid_request"); } // Get virtual key ID from authentication context @@ -69,19 +68,19 @@ public async Task ExecuteFunction( if (string.IsNullOrEmpty(virtualKeyId) || !int.TryParse(virtualKeyId, out var keyId)) { _logger.LogWarning("Invalid or missing VirtualKeyId claim"); - return Unauthorized(new ErrorResponseDto("Invalid authentication")); + return OpenAIError(401, "Invalid authentication", "invalid_auth", "authentication_error"); } // Validate function configuration exists and is enabled var configuration = await _configurationRepository.GetByIdAsync(request.FunctionConfigurationId, cancellationToken); if (configuration == null) { - return NotFound(new ErrorResponseDto($"Function configuration {request.FunctionConfigurationId} not found")); + return OpenAIError(404, $"Function configuration {request.FunctionConfigurationId} not found", "not_found", "not_found_error"); } if (!configuration.IsEnabled) { - return BadRequest(new ErrorResponseDto($"Function configuration {request.FunctionConfigurationId} is disabled")); + return OpenAIError(400, $"Function configuration {request.FunctionConfigurationId} is disabled", "invalid_request"); } // Validate parameters against schema if available @@ -96,8 +95,7 @@ public async Task ExecuteFunction( configuration.Id, string.Join(", ", validationResult.Errors)); - return BadRequest(new ErrorResponseDto( - $"Parameter validation failed: {string.Join("; ", validationResult.Errors)}")); + return OpenAIError(400, $"Parameter validation failed: {string.Join("; ", validationResult.Errors)}", "invalid_request"); } if (validationResult.Warnings.Count > 0) @@ -157,13 +155,12 @@ public async Task ExecuteFunction( catch (InvalidOperationException ex) { _logger.LogWarning(ex, "Invalid function execution request"); - return BadRequest(new ErrorResponseDto(ex.Message)); + return OpenAIError(400, ex.Message, "invalid_request"); } catch (Exception ex) { _logger.LogError(ex, "Error executing function"); - return StatusCode(StatusCodes.Status500InternalServerError, - new ErrorResponseDto("An unexpected error occurred during function execution")); + return OpenAIError(500, "An unexpected error occurred during function execution", "internal_error", "server_error"); } } @@ -188,14 +185,14 @@ public async Task GetExecution( if (string.IsNullOrEmpty(virtualKeyId) || !int.TryParse(virtualKeyId, out var keyId)) { _logger.LogWarning("Invalid or missing VirtualKeyId claim"); - return Unauthorized(new ErrorResponseDto("Invalid authentication")); + return OpenAIError(401, "Invalid authentication", "invalid_auth", "authentication_error"); } var execution = await _executionService.GetExecutionAsync(executionId, cancellationToken); if (execution == null) { - return NotFound(new ErrorResponseDto($"Function execution {executionId} not found")); + return OpenAIError(404, $"Function execution {executionId} not found", "not_found", "not_found_error"); } // Verify the execution belongs to the authenticated virtual key @@ -204,7 +201,7 @@ public async Task GetExecution( _logger.LogWarning( "Virtual key {VirtualKeyId} attempted to access execution {ExecutionId} owned by key {OwnerKeyId}", keyId, executionId, execution.VirtualKeyId); - return NotFound(new ErrorResponseDto($"Function execution {executionId} not found")); + return OpenAIError(404, $"Function execution {executionId} not found", "not_found", "not_found_error"); } var response = new FunctionExecutionResponse @@ -228,8 +225,7 @@ public async Task GetExecution( catch (Exception ex) { _logger.LogError(ex, "Error getting function execution {ExecutionId}", executionId); - return StatusCode(StatusCodes.Status500InternalServerError, - new ErrorResponseDto("An unexpected error occurred")); + return OpenAIError(500, "An unexpected error occurred", "internal_error", "server_error"); } } diff --git a/Services/ConduitLLM.Gateway/Controllers/HealthMonitoringTestController.cs b/Services/ConduitLLM.Gateway/Controllers/HealthMonitoringTestController.cs deleted file mode 100644 index 37e1e5581..000000000 --- a/Services/ConduitLLM.Gateway/Controllers/HealthMonitoringTestController.cs +++ /dev/null @@ -1,502 +0,0 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; -using ConduitLLM.Configuration.DTOs.HealthMonitoring; -using ConduitLLM.Gateway.Services; -using ConduitLLM.Gateway.Interfaces; -using ConduitLLM.Security.Interfaces; - -namespace ConduitLLM.Gateway.Controllers -{ - /// - /// Test controller for simulating various failure scenarios to test the health monitoring system - /// - [ApiController] - [Route("api/test/health-monitoring")] - [Authorize(Policy = "AdminOnly")] - public class HealthMonitoringTestController : ControllerBase - { - private readonly ILogger _logger; - private readonly IAlertManagementService _alertManagementService; - private readonly IPerformanceMonitoringService _performanceMonitoring; - private readonly ISecurityEventMonitoringService _securityEventMonitoring; - private readonly IMemoryCache _memoryCache; - private static readonly Dictionary _activeSimulations = new(); - - public HealthMonitoringTestController( - ILogger logger, - IAlertManagementService alertManagementService, - IPerformanceMonitoringService performanceMonitoring, - ISecurityEventMonitoringService securityEventMonitoring, - IMemoryCache memoryCache) - { - _logger = logger; - _alertManagementService = alertManagementService; - _performanceMonitoring = performanceMonitoring; - _securityEventMonitoring = securityEventMonitoring; - _memoryCache = memoryCache; - } - - /// - /// Get available test scenarios - /// - [HttpGet("scenarios")] - public IActionResult GetTestScenarios() - { - var scenarios = new[] - { - new { Id = "service-down", Name = "Simulate Service Down", Description = "Simulates a critical service being unavailable" }, - new { Id = "high-cpu", Name = "High CPU Usage", Description = "Simulates high CPU utilization" }, - new { Id = "memory-leak", Name = "Memory Leak", Description = "Simulates gradual memory exhaustion" }, - new { Id = "slow-response", Name = "Slow Response Times", Description = "Simulates degraded API performance" }, - new { Id = "high-error-rate", Name = "High Error Rate", Description = "Simulates increased API errors" }, - new { Id = "brute-force", Name = "Brute Force Attack", Description = "Simulates authentication attack" }, - new { Id = "rate-limit-breach", Name = "Rate Limit Violations", Description = "Simulates excessive API usage" }, - new { Id = "data-exfiltration", Name = "Data Exfiltration", Description = "Simulates suspicious data transfer" }, - new { Id = "connection-pool", Name = "Connection Pool Exhaustion", Description = "Simulates database connection issues" }, - new { Id = "disk-space", Name = "Low Disk Space", Description = "Simulates disk space exhaustion" } - }; - - return Ok(scenarios); - } - - /// - /// Start a test scenario - /// - [HttpPost("start/{scenario}")] - public Task StartScenario(string scenario, [FromQuery] int durationSeconds = 60) - { - if (_activeSimulations.ContainsKey(scenario)) - { - return Task.FromResult(BadRequest($"Scenario '{scenario}' is already running")); - } - - var cts = new CancellationTokenSource(); - _activeSimulations[scenario] = cts; - - _logger.LogWarning("Starting test scenario: {Scenario} for {Duration} seconds", scenario, durationSeconds); - - // Start scenario in background - _ = Task.Run(async () => - { - try - { - await RunScenario(scenario, durationSeconds, cts.Token); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error running scenario {Scenario}", scenario); - } - finally - { - _activeSimulations.Remove(scenario); - } - }); - - return Task.FromResult(Ok(new { message = $"Started scenario '{scenario}' for {durationSeconds} seconds" })); - } - - /// - /// Stop a running test scenario - /// - [HttpPost("stop/{scenario}")] - public IActionResult StopScenario(string scenario) - { - if (_activeSimulations.TryGetValue(scenario, out var cts)) - { - cts.Cancel(); - _activeSimulations.Remove(scenario); - _logger.LogInformation("Stopped test scenario: {Scenario}", scenario); - return Ok(new { message = $"Stopped scenario '{scenario}'" }); - } - - return NotFound($"Scenario '{scenario}' is not running"); - } - - /// - /// Get currently running scenarios - /// - [HttpGet("active")] - public IActionResult GetActiveScenarios() - { - return Ok(_activeSimulations.Keys.ToList()); - } - - /// - /// Trigger a custom alert - /// - [HttpPost("alert")] - public async Task TriggerCustomAlert([FromBody] CustomAlertRequest request) - { - var alert = new HealthAlert - { - Severity = request.Severity, - Type = AlertType.Custom, - Component = request.Component ?? "Test", - Title = request.Title, - Message = request.Message, - Context = new Dictionary - { - ["Source"] = "Test Controller", - ["TriggeredBy"] = User.Identity?.Name ?? "Unknown", - ["IsTest"] = true - }, - SuggestedActions = request.SuggestedActions ?? new List() - }; - - await _alertManagementService.TriggerAlertAsync(alert); - - return Ok(new { alertId = alert.Id, message = "Alert triggered successfully" }); - } - - private async Task RunScenario(string scenario, int durationSeconds, CancellationToken cancellationToken) - { - var endTime = DateTime.UtcNow.AddSeconds(durationSeconds); - - switch (scenario) - { - case "service-down": - await SimulateServiceDown(endTime, cancellationToken); - break; - case "high-cpu": - await SimulateHighCpu(endTime, cancellationToken); - break; - case "memory-leak": - await SimulateMemoryLeak(endTime, cancellationToken); - break; - case "slow-response": - await SimulateSlowResponse(endTime, cancellationToken); - break; - case "high-error-rate": - await SimulateHighErrorRate(endTime, cancellationToken); - break; - case "brute-force": - await SimulateBruteForce(endTime, cancellationToken); - break; - case "rate-limit-breach": - await SimulateRateLimitBreach(endTime, cancellationToken); - break; - case "data-exfiltration": - await SimulateDataExfiltration(endTime, cancellationToken); - break; - case "connection-pool": - await SimulateConnectionPoolExhaustion(endTime, cancellationToken); - break; - case "disk-space": - await SimulateLowDiskSpace(endTime, cancellationToken); - break; - default: - _logger.LogWarning("Unknown scenario: {Scenario}", scenario); - break; - } - } - - private async Task SimulateServiceDown(DateTime endTime, CancellationToken cancellationToken) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Critical, - Type = AlertType.ServiceDown, - Component = "Database", - Title = "Database Connection Failed", - Message = "Unable to connect to primary database server", - Context = new Dictionary - { - ["ConnectionString"] = "Server=db.example.com;Database=conduit;", - ["LastSuccessfulConnection"] = DateTime.UtcNow.AddMinutes(-5), - ["AttemptsCount"] = 10, - ["IsSimulated"] = true - }, - SuggestedActions = new List - { - "Check database server status", - "Verify network connectivity", - "Review database logs", - "Check connection string configuration" - } - }); - - // Simulate periodic retry attempts - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - await Task.Delay(10000, cancellationToken); // Every 10 seconds - - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Error, - Type = AlertType.ConnectivityIssue, - Component = "Database", - Title = "Database Reconnection Failed", - Message = "Retry attempt failed to establish database connection", - Context = new Dictionary - { - ["RetryCount"] = DateTime.UtcNow.Subtract(endTime.AddSeconds(-60)).TotalSeconds / 10, - ["IsSimulated"] = true - } - }); - } - } - - private async Task SimulateHighCpu(DateTime endTime, CancellationToken cancellationToken) - { - var cpuTasks = new List(); - - // Create CPU-intensive tasks - for (int i = 0; i < Environment.ProcessorCount; i++) - { - cpuTasks.Add(Task.Run(() => - { - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - // CPU-intensive calculation - double result = 0; - for (int j = 0; j < 1000000; j++) - { - result += Math.Sqrt(j) * Math.Sin(j); - } - } - }, cancellationToken)); - } - - // Monitor and report high CPU - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - await Task.Delay(5000, cancellationToken); - - var process = Process.GetCurrentProcess(); - var cpuTime = process.TotalProcessorTime.TotalMilliseconds; - - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.ResourceExhaustion, - Component = "System", - Title = "High CPU Usage Detected", - Message = "CPU usage is above threshold", - Context = new Dictionary - { - ["CpuTimeMs"] = cpuTime, - ["ThreadCount"] = process.Threads.Count, - ["IsSimulated"] = true - } - }); - } - - await Task.WhenAll(cpuTasks); - } - - private async Task SimulateMemoryLeak(DateTime endTime, CancellationToken cancellationToken) - { - var leakedMemory = new List(); - var allocationSize = 10 * 1024 * 1024; // 10MB chunks - - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - try - { - // Allocate memory that won't be freed - leakedMemory.Add(new byte[allocationSize]); - - // Fill with data to ensure it's actually allocated - var lastArray = leakedMemory.Last(); - new Random().NextBytes(lastArray); - - // Report memory usage - var process = Process.GetCurrentProcess(); - var memoryMB = process.WorkingSet64 / (1024 * 1024); - - if (memoryMB > 500) // Alert if over 500MB - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.ResourceExhaustion, - Component = "Memory", - Title = "High Memory Usage Detected", - Message = $"Process memory usage: {memoryMB}MB", - Context = new Dictionary - { - ["WorkingSetMB"] = memoryMB, - ["GCGen0"] = GC.CollectionCount(0), - ["GCGen1"] = GC.CollectionCount(1), - ["GCGen2"] = GC.CollectionCount(2), - ["IsSimulated"] = true - } - }); - } - - await Task.Delay(2000, cancellationToken); // Every 2 seconds - } - catch (OutOfMemoryException) - { - _logger.LogWarning("Simulated memory leak reached system limits"); - break; - } - } - - // Cleanup - leakedMemory.Clear(); - GC.Collect(); - } - - private async Task SimulateSlowResponse(DateTime endTime, CancellationToken cancellationToken) - { - var endpoints = new[] { "/v1/chat/completions", "/v1/embeddings", "/v1/images/generations" }; - var random = new Random(); - - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - var endpoint = endpoints[random.Next(endpoints.Length)]; - var responseTime = random.Next(3000, 10000); // 3-10 seconds - - _performanceMonitoring.RecordRequestMetric(endpoint, responseTime, true); - - if (responseTime > 5000) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.PerformanceDegradation, - Component = "API", - Title = "Slow API Response", - Message = $"Endpoint {endpoint} responded in {responseTime}ms", - Context = new Dictionary - { - ["Endpoint"] = endpoint, - ["ResponseTimeMs"] = responseTime, - ["Threshold"] = 5000, - ["IsSimulated"] = true - } - }); - } - - await Task.Delay(1000, cancellationToken); - } - } - - private async Task SimulateHighErrorRate(DateTime endTime, CancellationToken cancellationToken) - { - var endpoints = new[] { "/v1/chat/completions", "/v1/embeddings", "/v1/images/generations" }; - var random = new Random(); - - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - var endpoint = endpoints[random.Next(endpoints.Length)]; - var isError = random.Next(100) < 30; // 30% error rate - - _performanceMonitoring.RecordRequestMetric(endpoint, random.Next(100, 500), !isError); - - if (isError) - { - _logger.LogError("Simulated error for endpoint {Endpoint}", endpoint); - } - - await Task.Delay(100, cancellationToken); // High frequency - } - } - - private async Task SimulateBruteForce(DateTime endTime, CancellationToken cancellationToken) - { - var attackerIps = new[] { "192.168.1.100", "10.0.0.50", "172.16.0.25" }; - var virtualKeys = new[] { "vk_test_key_001", "vk_test_key_002", "vk_test_key_003" }; - var random = new Random(); - - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - var ip = attackerIps[random.Next(attackerIps.Length)]; - var key = virtualKeys[random.Next(virtualKeys.Length)] + random.Next(1000); - var endpoint = "/v1/chat/completions"; - - _securityEventMonitoring.RecordAuthenticationFailure(ip, key, endpoint); - - await Task.Delay(200, cancellationToken); // Rapid attempts - } - } - - private async Task SimulateRateLimitBreach(DateTime endTime, CancellationToken cancellationToken) - { - var ip = "192.168.1.200"; - var virtualKey = "vk_test_heavy_user"; - var endpoint = "/v1/chat/completions"; - - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - for (int i = 0; i < 10; i++) - { - _securityEventMonitoring.RecordRateLimitViolation(ip, virtualKey, endpoint, "RPM"); - } - - await Task.Delay(1000, cancellationToken); - } - } - - private async Task SimulateDataExfiltration(DateTime endTime, CancellationToken cancellationToken) - { - var ip = "10.0.0.100"; - var virtualKey = "vk_test_suspicious"; - var endpoints = new[] { "/v1/embeddings", "/v1/chat/completions" }; - var random = new Random(); - - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - var endpoint = endpoints[random.Next(endpoints.Length)]; - var dataSize = random.Next(10_000_000, 100_000_000); // 10MB to 100MB - - _securityEventMonitoring.RecordDataExfiltrationAttempt(ip, virtualKey, dataSize, endpoint); - - await Task.Delay(5000, cancellationToken); - } - } - - private async Task SimulateConnectionPoolExhaustion(DateTime endTime, CancellationToken cancellationToken) - { - while (DateTime.UtcNow < endTime && !cancellationToken.IsCancellationRequested) - { - _performanceMonitoring.RecordConnectionPoolMetric("PostgreSQL", 95, 5, 20); - _performanceMonitoring.RecordConnectionPoolMetric("Redis", 48, 2, 15); - - await Task.Delay(2000, cancellationToken); - } - } - - private async Task SimulateLowDiskSpace(DateTime endTime, CancellationToken cancellationToken) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Critical, - Type = AlertType.ResourceExhaustion, - Component = "Disk", - Title = "Low Disk Space", - Message = "Primary disk has less than 5% free space", - Context = new Dictionary - { - ["DiskPath"] = "/", - ["TotalGB"] = 100, - ["FreeGB"] = 4.5, - ["UsedPercent"] = 95.5, - ["IsSimulated"] = true - }, - SuggestedActions = new List - { - "Clean up old log files", - "Remove temporary files", - "Archive old media assets", - "Increase disk capacity" - } - }); - - // Wait for scenario duration - await Task.Delay((int)(endTime - DateTime.UtcNow).TotalMilliseconds, cancellationToken); - } - - public class CustomAlertRequest - { - public AlertSeverity Severity { get; set; } = AlertSeverity.Warning; - public string Title { get; set; } = ""; - public string Message { get; set; } = ""; - public string? Component { get; set; } - public List? SuggestedActions { get; set; } - } - } -} diff --git a/Services/ConduitLLM.Gateway/Controllers/ImagesController.Async.cs b/Services/ConduitLLM.Gateway/Controllers/ImagesController.Async.cs index 671afeab2..ab40bc23e 100644 --- a/Services/ConduitLLM.Gateway/Controllers/ImagesController.Async.cs +++ b/Services/ConduitLLM.Gateway/Controllers/ImagesController.Async.cs @@ -1,7 +1,11 @@ +using System.Diagnostics; using ConduitLLM.Core.Models; using ConduitLLM.Core.Constants; using ConduitLLM.Core.Events; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Gateway.Metrics; +using GatewayOpsMetrics = ConduitLLM.Gateway.Services.GatewayOperationsMetricsService; using Microsoft.AspNetCore.Mvc; namespace ConduitLLM.Gateway.Controllers @@ -19,6 +23,9 @@ public partial class ImagesController [HttpPost("generations/async")] public async Task CreateImageAsync([FromBody] ConduitLLM.Core.Models.ImageGenerationRequest request) { + using var activity = GatewayRequestMetrics.StartImageGenerationActivity( + request.Model ?? "unknown", isAsync: true); + try { // Validate request @@ -60,12 +67,12 @@ public async Task CreateImageAsync([FromBody] ConduitLLM.Core.Mod if (mapping != null) { supportsImageGen = mapping.ModelProviderTypeAssociation?.Model?.SupportsImageGeneration ?? false; - _logger.LogInformation("Model {Model} mapping found, supports image generation: {Supports}", - modelName, supportsImageGen); + _logger.LogInformation("Model {Model} mapping found, supports image generation: {Supports}", + LoggingSanitizer.S(modelName), supportsImageGen); } else { - _logger.LogWarning("No mapping found for model {Model}. Model must be configured in model mappings.", modelName); + _logger.LogWarning("No mapping found for model {Model}. Model must be configured in model mappings.", LoggingSanitizer.S(modelName)); supportsImageGen = false; } @@ -83,34 +90,17 @@ public async Task CreateImageAsync([FromBody] ConduitLLM.Core.Mod }); } - // Get virtual key ID from authenticated user claims - var virtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - if (string.IsNullOrEmpty(virtualKeyIdClaim) || !int.TryParse(virtualKeyIdClaim, out var virtualKeyId)) + if (CurrentVirtualKeyId == null) { - return Unauthorized(new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Invalid authentication", - Type = "invalid_request_error", - Code = "unauthorized" - } - }); + return OpenAIError(401, "Virtual key not found in request context", "unauthorized"); } + var virtualKeyId = CurrentVirtualKeyId.Value; // Get virtual key information from service var virtualKey = await _virtualKeyService.GetVirtualKeyInfoForValidationAsync(virtualKeyId); if (virtualKey == null) { - return Unauthorized(new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Virtual key not found", - Type = "invalid_request_error", - Code = "unauthorized" - } - }); + return OpenAIError(401, "Virtual key not found", "unauthorized"); } // Create correlation ID @@ -164,8 +154,8 @@ public async Task CreateImageAsync([FromBody] ConduitLLM.Core.Mod // Publish the event directly to MassTransit for immediate processing PublishEventFireAndForget(generationRequest, "create async image generation", new { TaskId = taskId, Model = modelName }); - _logger.LogInformation("Created async image generation task {TaskId} for model {Model} and published event", - taskId, modelName); + _logger.LogInformation("Created async image generation task {TaskId} for model {Model} and published event", + taskId, LoggingSanitizer.S(modelName)); // Return accepted response with task information var response = new AsyncTaskResponse @@ -176,20 +166,14 @@ public async Task CreateImageAsync([FromBody] ConduitLLM.Core.Mod CreatedAt = DateTime.UtcNow }; + GatewayOpsMetrics.RecordMediaOperation("generate", "image_async", "queued"); return Accepted(response); } catch (Exception ex) { _logger.LogError(ex, "Error creating async image generation task"); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "An error occurred while creating the task", - Type = "server_error", - Code = "internal_error" - } - }); + GatewayOpsMetrics.RecordMediaOperation("generate", "image_async", "error"); + return OpenAIError(500, "An error occurred while creating the task", "internal_error", "server_error"); } } @@ -222,27 +206,16 @@ public async Task GetGenerationStatus(string taskId) }); } - _logger.LogInformation("Task {TaskId} retrieved, State: {State}, HasMetadata: {HasMetadata}", + _logger.LogInformation("Task {TaskId} retrieved, State: {State}, HasMetadata: {HasMetadata}", taskId, task.State, task.Metadata != null); - // Verify user owns this task by comparing virtual key IDs - var userVirtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - if (task.Metadata != null && !string.IsNullOrEmpty(userVirtualKeyIdClaim) && int.TryParse(userVirtualKeyIdClaim, out var userVirtualKeyId)) + // Verify user owns this task. Return 404 (not 403) to avoid leaking task existence. + var callerVirtualKeyId = CurrentVirtualKeyId; + if (callerVirtualKeyId != null && task.Metadata != null && task.Metadata.VirtualKeyId != callerVirtualKeyId.Value) { - var metadataJson = System.Text.Json.JsonSerializer.Serialize(task.Metadata); - var metadataDict = System.Text.Json.JsonSerializer.Deserialize>(metadataJson); - if (metadataDict != null && metadataDict.TryGetValue("virtualKeyId", out var keyIdObj)) - { - var taskVirtualKeyId = Convert.ToInt32(keyIdObj.ToString()); - _logger.LogInformation("Validating task ownership - Task VirtualKeyId: {TaskKeyId}, User VirtualKeyId: {UserKeyId}", - taskVirtualKeyId, userVirtualKeyId); - - // Compare the virtual key IDs - if (taskVirtualKeyId != userVirtualKeyId) - { - _logger.LogWarning("Virtual key ID mismatch for task {TaskId} - Expected: {Expected}, Got: {Got}", - taskId, taskVirtualKeyId, userVirtualKeyId); - return NotFound(new OpenAIErrorResponse + _logger.LogWarning("Virtual key {CallerKeyId} attempted to access task {TaskId} owned by {OwnerKeyId}", + callerVirtualKeyId.Value, taskId, task.Metadata.VirtualKeyId); + return NotFound(new OpenAIErrorResponse { Error = new OpenAIError { @@ -252,10 +225,6 @@ public async Task GetGenerationStatus(string taskId) Param = "task_id" } }); - } - - _logger.LogInformation("Virtual key validation successful for task {TaskId}", taskId); - } } // Build response @@ -275,15 +244,7 @@ public async Task GetGenerationStatus(string taskId) catch (Exception ex) { _logger.LogError(ex, "Error getting task status for {TaskId}", taskId); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "An error occurred while getting task status", - Type = "server_error", - Code = "internal_error" - } - }); + return OpenAIError(500, "An error occurred while getting task status", "internal_error", "server_error"); } } @@ -313,24 +274,13 @@ public async Task CancelGeneration(string taskId) }); } - // Verify user owns this task by comparing virtual key IDs - var userVirtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - if (task.Metadata != null && !string.IsNullOrEmpty(userVirtualKeyIdClaim) && int.TryParse(userVirtualKeyIdClaim, out var userVirtualKeyId)) + // Verify user owns this task. Return 404 (not 403) to avoid leaking task existence. + var callerVirtualKeyId = CurrentVirtualKeyId; + if (callerVirtualKeyId != null && task.Metadata != null && task.Metadata.VirtualKeyId != callerVirtualKeyId.Value) { - var metadataJson = System.Text.Json.JsonSerializer.Serialize(task.Metadata); - var metadataDict = System.Text.Json.JsonSerializer.Deserialize>(metadataJson); - if (metadataDict != null && metadataDict.TryGetValue("virtualKeyId", out var keyIdObj)) - { - var taskVirtualKeyId = Convert.ToInt32(keyIdObj.ToString()); - _logger.LogInformation("Validating task ownership - Task VirtualKeyId: {TaskKeyId}, User VirtualKeyId: {UserKeyId}", - taskVirtualKeyId, userVirtualKeyId); - - // Compare the virtual key IDs - if (taskVirtualKeyId != userVirtualKeyId) - { - _logger.LogWarning("Virtual key ID mismatch for task {TaskId} - Expected: {Expected}, Got: {Got}", - taskId, taskVirtualKeyId, userVirtualKeyId); - return NotFound(new OpenAIErrorResponse + _logger.LogWarning("Virtual key {CallerKeyId} attempted to cancel task {TaskId} owned by {OwnerKeyId}", + callerVirtualKeyId.Value, taskId, task.Metadata.VirtualKeyId); + return NotFound(new OpenAIErrorResponse { Error = new OpenAIError { @@ -340,10 +290,6 @@ public async Task CancelGeneration(string taskId) Param = "task_id" } }); - } - - _logger.LogInformation("Virtual key validation successful for task {TaskId}", taskId); - } } // Check if task can be cancelled @@ -360,23 +306,11 @@ public async Task CancelGeneration(string taskId) }); } - // Get virtual key ID from metadata for event publishing - var cancelVirtualKeyId = 0; - if (task.Metadata != null) - { - var metadataJson = System.Text.Json.JsonSerializer.Serialize(task.Metadata); - var metadataDict = System.Text.Json.JsonSerializer.Deserialize>(metadataJson); - if (metadataDict != null && metadataDict.TryGetValue("virtualKeyId", out var keyIdObj)) - { - cancelVirtualKeyId = Convert.ToInt32(keyIdObj.ToString()); - } - } - - // Publish cancellation event + // Publish cancellation event using the task owner's virtual key ID PublishEventFireAndForget(new ImageGenerationCancelled { TaskId = taskId, - VirtualKeyId = cancelVirtualKeyId, + VirtualKeyId = task.Metadata?.VirtualKeyId ?? 0, Reason = "Cancelled by user request", CancelledAt = DateTime.UtcNow, CorrelationId = Guid.NewGuid().ToString() @@ -389,15 +323,7 @@ public async Task CancelGeneration(string taskId) catch (Exception ex) { _logger.LogError(ex, "Error cancelling task {TaskId}", taskId); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "An error occurred while cancelling the task", - Type = "server_error", - Code = "internal_error" - } - }); + return OpenAIError(500, "An error occurred while cancelling the task", "internal_error", "server_error"); } } } diff --git a/Services/ConduitLLM.Gateway/Controllers/ImagesController.Sync.cs b/Services/ConduitLLM.Gateway/Controllers/ImagesController.Sync.cs index f7e0c1401..bf93afc66 100644 --- a/Services/ConduitLLM.Gateway/Controllers/ImagesController.Sync.cs +++ b/Services/ConduitLLM.Gateway/Controllers/ImagesController.Sync.cs @@ -1,5 +1,10 @@ +using System.Diagnostics; +using System.Net; +using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Constants; +using ConduitLLM.Gateway.UsageTracking; +using GatewayOpsMetrics = ConduitLLM.Gateway.Services.GatewayOperationsMetricsService; using Microsoft.AspNetCore.Mvc; @@ -14,10 +19,15 @@ public partial class ImagesController /// Creates one or more images given a prompt. /// /// The image generation request. + /// Cancellation token from the HTTP request. /// Generated images. [HttpPost("generations")] - public async Task CreateImage([FromBody] ConduitLLM.Core.Models.ImageGenerationRequest request) + public async Task CreateImage( + [FromBody] ConduitLLM.Core.Models.ImageGenerationRequest request, + CancellationToken cancellationToken = default) { + var sw = Stopwatch.StartNew(); + ConduitLLM.Configuration.Entities.ModelProviderMapping? mapping = null; try { // Validate request @@ -52,15 +62,19 @@ public async Task CreateImage([FromBody] ConduitLLM.Core.Models.I var modelName = request.Model; - // Store image request details for usage tracking - // These are stored before mapping lookup since request.Model may be updated later - HttpContext.Items[HttpContextKeys.ImageRequestModel] = modelName; - HttpContext.Items[HttpContextKeys.ImageRequestQuality] = request.Quality; - HttpContext.Items[HttpContextKeys.ImageRequestSize] = request.Size; - HttpContext.Items[HttpContextKeys.ImageRequestN] = request.N; + // Store image request details for usage tracking. + // Set before mapping lookup since request.Model may be updated to the provider model ID later. + HttpContext.SetUsageContext(new ImageUsageContext + { + Model = modelName, + Quality = request.Quality, + Size = request.Size, + N = request.N, + Style = request.Style + }); // First check model mappings for image generation capability - var mapping = await _modelMappingService.GetMappingByModelAliasAsync(modelName); + mapping = await _modelMappingService.GetMappingByModelAliasAsync(modelName); bool supportsImageGen = false; if (mapping != null) @@ -109,7 +123,7 @@ public async Task CreateImage([FromBody] ConduitLLM.Core.Models.I } // Create client for the model - var client = _clientFactory.GetClient(modelName); + var client = await _clientFactory.GetClientAsync(modelName); // Update request with the provider's model ID if we have a mapping if (mapping != null) @@ -118,7 +132,7 @@ public async Task CreateImage([FromBody] ConduitLLM.Core.Models.I } // Generate images - var response = await client.CreateImageAsync(request); + var response = await client.CreateImageAsync(request, cancellationToken: cancellationToken); // Store generated images if they're base64 or external URLs for (int i = 0; i < response.Data.Count; i++) @@ -253,9 +267,8 @@ public async Task CreateImage([FromBody] ConduitLLM.Core.Models.I // Track media ownership for lifecycle management try { - // Get virtual key ID from HttpContext - var virtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - if (!string.IsNullOrEmpty(virtualKeyIdClaim) && int.TryParse(virtualKeyIdClaim, out var virtualKeyId)) + var virtualKeyId = CurrentVirtualKeyId; + if (virtualKeyId != null) { var mediaMetadata = new Core.Interfaces.MediaLifecycleMetadata { @@ -269,13 +282,13 @@ public async Task CreateImage([FromBody] ConduitLLM.Core.Models.I }; await _mediaLifecycleService.TrackMediaAsync( - virtualKeyId, + virtualKeyId.Value, storageResult.StorageKey, "image", mediaMetadata); - - _logger.LogInformation("Tracked media {StorageKey} for virtual key {VirtualKeyId}", - storageResult.StorageKey, virtualKeyId); + + _logger.LogInformation("Tracked media {StorageKey} for virtual key {VirtualKeyId}", + storageResult.StorageKey, virtualKeyId.Value); } else { @@ -321,21 +334,96 @@ await _mediaLifecycleService.TrackMediaAsync( } } + GatewayOpsMetrics.RecordMediaOperation("generate", "image", "success", sw.Elapsed.TotalSeconds, request.Model); return Ok(response); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Image generation failed for model {Model}: {ErrorType} - {Message}", + request.Model, ex.GetType().Name, ex.Message); + GatewayOpsMetrics.RecordMediaOperation("generate", "image", "error", sw.Elapsed.TotalSeconds, request.Model); + + // Track error in the provider error system for dashboard visibility and auto-disable + var keyCredentialId = mapping?.Provider?.ProviderKeyCredentials?.FirstOrDefault(k => k.IsPrimary)?.Id + ?? mapping?.Provider?.ProviderKeyCredentials?.FirstOrDefault()?.Id; + await TrackProviderErrorAsync(ex, request.Model, mapping?.ProviderId, keyCredentialId); + + // Rethrow โ€” OpenAIErrorMiddleware maps exceptions to proper HTTP responses + // via ExceptionToResponseMapper (e.g., 429 for RateLimitExceeded, 408 for Timeout, etc.) + throw; + } + } + + /// + /// Classifies an exception and tracks it in the provider error system. + /// + private async Task TrackProviderErrorAsync(Exception ex, string? modelName, int? providerId, int? keyCredentialId) + { + try { - _logger.LogError(ex, "Error generating images"); - return StatusCode(500, new OpenAIErrorResponse + if (providerId == null || keyCredentialId == null) { - Error = new OpenAIError - { - Message = "An error occurred while generating images", - Type = "server_error", - Code = "internal_error" - } - }); + _logger.LogWarning("Cannot track provider error โ€” missing provider context (ProviderId={ProviderId}, KeyCredentialId={KeyCredentialId})", + providerId, keyCredentialId); + return; + } + + var errorType = ClassifyExceptionToProviderErrorType(ex); + int? httpStatusCode = (ex as LLMCommunicationException)?.StatusCode.HasValue == true + ? (int)(ex as LLMCommunicationException)!.StatusCode!.Value + : null; + + var errorInfo = new ConduitLLM.Core.Models.ProviderErrorInfo + { + KeyCredentialId = keyCredentialId.Value, + ProviderId = providerId.Value, + ErrorType = errorType, + ErrorMessage = ex.Message, + HttpStatusCode = httpStatusCode, + ModelName = modelName, + OccurredAt = DateTime.UtcNow, + RequestId = HttpContext.TraceIdentifier + }; + + await _errorTrackingService.TrackErrorAsync(errorInfo); + + _logger.LogInformation("Tracked provider error: Type={ErrorType}, Provider={ProviderId}, Key={KeyCredentialId}, Model={Model}", + errorType, providerId, keyCredentialId, modelName); + } + catch (Exception trackEx) + { + // Never let error tracking prevent the original error from propagating + _logger.LogWarning(trackEx, "Failed to track provider error for model {Model}", modelName); } } + + /// + /// Maps an exception to a for error tracking. + /// + private static ConduitLLM.Core.Models.ProviderErrorType ClassifyExceptionToProviderErrorType(Exception ex) + { + return ex switch + { + LLMCommunicationException commEx when commEx.StatusCode.HasValue => commEx.StatusCode.Value switch + { + HttpStatusCode.Unauthorized => ConduitLLM.Core.Models.ProviderErrorType.InvalidApiKey, + HttpStatusCode.PaymentRequired => ConduitLLM.Core.Models.ProviderErrorType.InsufficientBalance, + HttpStatusCode.Forbidden => ConduitLLM.Core.Models.ProviderErrorType.AccessForbidden, + HttpStatusCode.TooManyRequests => ConduitLLM.Core.Models.ProviderErrorType.RateLimitExceeded, + HttpStatusCode.NotFound => ConduitLLM.Core.Models.ProviderErrorType.ModelNotFound, + HttpStatusCode.ServiceUnavailable => ConduitLLM.Core.Models.ProviderErrorType.ServiceUnavailable, + HttpStatusCode.BadGateway => ConduitLLM.Core.Models.ProviderErrorType.ServiceUnavailable, + HttpStatusCode.GatewayTimeout => ConduitLLM.Core.Models.ProviderErrorType.Timeout, + HttpStatusCode.RequestTimeout => ConduitLLM.Core.Models.ProviderErrorType.Timeout, + _ => ConduitLLM.Core.Models.ProviderErrorType.Unknown + }, + RateLimitExceededException => ConduitLLM.Core.Models.ProviderErrorType.RateLimitExceeded, + RequestTimeoutException => ConduitLLM.Core.Models.ProviderErrorType.Timeout, + ModelNotFoundException => ConduitLLM.Core.Models.ProviderErrorType.ModelNotFound, + ServiceUnavailableException => ConduitLLM.Core.Models.ProviderErrorType.ServiceUnavailable, + HttpRequestException => ConduitLLM.Core.Models.ProviderErrorType.NetworkError, + _ => ConduitLLM.Core.Models.ProviderErrorType.Unknown + }; + } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Controllers/ImagesController.cs b/Services/ConduitLLM.Gateway/Controllers/ImagesController.cs index 0fe6b6a1c..ed83ea436 100644 --- a/Services/ConduitLLM.Gateway/Controllers/ImagesController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/ImagesController.cs @@ -13,10 +13,10 @@ namespace ConduitLLM.Gateway.Controllers /// [ApiController] [Route("v1/images")] - [Authorize] + [Authorize(AuthenticationSchemes = "VirtualKey")] [RequireBalance] [Tags("Images")] - public partial class ImagesController : EventPublishingControllerBase + public partial class ImagesController : GatewayControllerBase { private readonly ILLMClientFactory _clientFactory; private readonly IMediaStorageService _storageService; @@ -26,6 +26,7 @@ public partial class ImagesController : EventPublishingControllerBase private readonly ConduitLLM.Core.Interfaces.IVirtualKeyService _virtualKeyService; private readonly IMediaLifecycleService _mediaLifecycleService; private readonly IHttpClientFactory _httpClientFactory; + private readonly IProviderErrorTrackingService _errorTrackingService; public ImagesController( ILLMClientFactory clientFactory, @@ -36,7 +37,8 @@ public ImagesController( IPublishEndpoint publishEndpoint, ConduitLLM.Core.Interfaces.IVirtualKeyService virtualKeyService, IMediaLifecycleService mediaLifecycleService, - IHttpClientFactory httpClientFactory) + IHttpClientFactory httpClientFactory, + IProviderErrorTrackingService errorTrackingService) : base(publishEndpoint, logger) { _clientFactory = clientFactory; @@ -47,6 +49,7 @@ public ImagesController( _virtualKeyService = virtualKeyService; _mediaLifecycleService = mediaLifecycleService; _httpClientFactory = httpClientFactory; + _errorTrackingService = errorTrackingService; } } } diff --git a/Services/ConduitLLM.Gateway/Controllers/MediaController.cs b/Services/ConduitLLM.Gateway/Controllers/MediaController.cs index d14d7bc05..db7a511c0 100644 --- a/Services/ConduitLLM.Gateway/Controllers/MediaController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/MediaController.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Controllers; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; @@ -13,17 +14,16 @@ namespace ConduitLLM.Gateway.Controllers [ApiController] [Route("v1/media")] [Authorize] - public class MediaController : ControllerBase + public class MediaController : GatewayControllerBase { private readonly IMediaStorageService _storageService; - private readonly ILogger _logger; public MediaController( IMediaStorageService storageService, ILogger logger) + : base(logger) { _storageService = storageService; - _logger = logger; } /// @@ -36,16 +36,16 @@ public MediaController( [Authorize] [Consumes("multipart/form-data")] [RequestSizeLimit(524288000)] // 500MB limit - public async Task UploadMedia( + public Task UploadMedia( IFormFile file, [FromForm] string? mediaType = null) { - try + return ExecuteAsync(async () => { // Validate file if (file == null || file.Length == 0) { - return BadRequest(new { error = "No file provided or file is empty" }); + return OpenAIError(400, "No file provided or file is empty", "invalid_request"); } // Validate file extension @@ -60,7 +60,7 @@ public async Task UploadMedia( { if (!Enum.TryParse(mediaType, true, out determinedMediaType)) { - return BadRequest(new { error = "Invalid media type. Must be Image, Video, or Audio" }); + return OpenAIError(400, "Invalid media type. Must be Image, Video, or Audio", "invalid_parameter"); } } else if (allowedImageExtensions.Contains(extension)) @@ -77,7 +77,7 @@ public async Task UploadMedia( } else { - return BadRequest(new { error = $"Unsupported file extension: {extension}" }); + return OpenAIError(400, $"Unsupported file extension: {extension}", "invalid_parameter"); } // Validate file size based on type @@ -92,7 +92,7 @@ public async Task UploadMedia( if (file.Length > maxSizeBytes) { var maxSizeMB = maxSizeBytes / (1024 * 1024); - return BadRequest(new { error = $"File size exceeds maximum allowed size of {maxSizeMB}MB for {determinedMediaType}" }); + return OpenAIError(400, $"File size exceeds maximum allowed size of {maxSizeMB}MB for {determinedMediaType}", "invalid_request"); } // Create metadata @@ -107,7 +107,7 @@ public async Task UploadMedia( using var stream = file.OpenReadStream(); var result = await _storageService.StoreAsync(stream, metadata); - _logger.LogInformation("Media uploaded successfully. Type: {MediaType}, Size: {Size} bytes, Key: {StorageKey}", + Logger.LogInformation("Media uploaded successfully. Type: {MediaType}, Size: {Size} bytes, Key: {StorageKey}", determinedMediaType, file.Length, result.StorageKey); // Return result with full URL @@ -123,18 +123,14 @@ public async Task UploadMedia( fileName = file.FileName, sizeBytes = file.Length }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error uploading media file"); - return StatusCode(500, new { error = "An error occurred while uploading the media file" }); - } + }, + "UploadMedia"); } /// /// Gets content type from file extension. /// - private string GetContentTypeFromExtension(string extension) + private static string GetContentTypeFromExtension(string extension) { return extension.ToLowerInvariant() switch { @@ -169,14 +165,14 @@ private string GetContentTypeFromExtension(string extension) /// The media file. [HttpGet("{**storageKey}")] [AllowAnonymous] // Media URLs should work without auth - public async Task GetMedia(string storageKey) + public Task GetMedia(string storageKey) { - try + return ExecuteAsync(async () => { // Validate storage key if (string.IsNullOrWhiteSpace(storageKey)) { - return BadRequest("Invalid storage key"); + return OpenAIError(400, "Invalid storage key", "invalid_parameter"); } // Get media info @@ -214,12 +210,9 @@ public async Task GetMedia(string storageKey) // Return file with proper content type return File(stream, mediaInfo.ContentType, enableRangeProcessing: true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving media with key {StorageKey}", storageKey); - return StatusCode(500, "An error occurred while retrieving the media"); - } + }, + "GetMedia", + storageKey); } /// @@ -228,9 +221,9 @@ public async Task GetMedia(string storageKey) /// The unique storage key. /// Media metadata. [HttpGet("info/{**storageKey}")] - public async Task GetMediaInfo(string storageKey) + public Task GetMediaInfo(string storageKey) { - try + return ExecuteAsync(async () => { var mediaInfo = await _storageService.GetInfoAsync(storageKey); if (mediaInfo == null) @@ -239,12 +232,9 @@ public async Task GetMediaInfo(string storageKey) } return Ok(mediaInfo); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving media info for key {StorageKey}", storageKey); - return StatusCode(500, "An error occurred while retrieving media information"); - } + }, + "GetMediaInfo", + storageKey); } /// @@ -254,9 +244,9 @@ public async Task GetMediaInfo(string storageKey) /// True if the media exists. [HttpHead("{**storageKey}")] [AllowAnonymous] - public async Task CheckMediaExists(string storageKey) + public Task CheckMediaExists(string storageKey) { - try + return ExecuteAsync(async () => { var exists = await _storageService.ExistsAsync(storageKey); if (!exists) @@ -272,12 +262,9 @@ public async Task CheckMediaExists(string storageKey) } return Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking media existence for key {StorageKey}", storageKey); - return StatusCode(500); - } + }, + "CheckMediaExists", + storageKey); } /// @@ -285,58 +272,50 @@ public async Task CheckMediaExists(string storageKey) /// private async Task HandleVideoRangeRequest(string storageKey, MediaInfo mediaInfo) { - try + var rangeHeader = Request.Headers[HeaderNames.Range].FirstOrDefault(); + if (string.IsNullOrEmpty(rangeHeader)) { - var rangeHeader = Request.Headers[HeaderNames.Range].FirstOrDefault(); - if (string.IsNullOrEmpty(rangeHeader)) - { - return BadRequest("Invalid range header"); - } - - // Parse range header (e.g., "bytes=0-1023") - var range = ParseRangeHeader(rangeHeader, mediaInfo.SizeBytes); - if (range == null) - { - return StatusCode(416, "Requested Range Not Satisfiable"); // 416 Range Not Satisfiable - } - - // Get video stream with range - var rangedStream = await _storageService.GetVideoStreamAsync( - storageKey, - range.Value.Start, - range.Value.End); + return OpenAIError(400, "Invalid range header", "invalid_request"); + } - if (rangedStream == null) - { - return NotFound(); - } + // Parse range header (e.g., "bytes=0-1023") + var range = ParseRangeHeader(rangeHeader, mediaInfo.SizeBytes); + if (range == null) + { + return StatusCode(416, "Requested Range Not Satisfiable"); // 416 Range Not Satisfiable + } - // Set response headers for partial content - Response.StatusCode = 206; // Partial Content - Response.Headers["Accept-Ranges"] = "bytes"; - Response.Headers["Content-Range"] = $"bytes {rangedStream.RangeStart}-{rangedStream.RangeEnd}/{rangedStream.TotalSize}"; - Response.Headers["Content-Length"] = rangedStream.ContentLength.ToString(); - Response.Headers["Cache-Control"] = "public, max-age=3600"; - Response.Headers["ETag"] = $"\"{storageKey}\""; - - // CORS headers for video playback - Response.Headers["Access-Control-Allow-Origin"] = "*"; - Response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"; - Response.Headers["Access-Control-Allow-Headers"] = "Range"; + // Get video stream with range + var rangedStream = await _storageService.GetVideoStreamAsync( + storageKey, + range.Value.Start, + range.Value.End); - return File(rangedStream.Stream, rangedStream.ContentType); - } - catch (Exception ex) + if (rangedStream == null) { - _logger.LogError(ex, "Error handling video range request for key {StorageKey}", storageKey); - return StatusCode(500, "An error occurred while streaming the video"); + return NotFound(); } + + // Set response headers for partial content + Response.StatusCode = 206; // Partial Content + Response.Headers["Accept-Ranges"] = "bytes"; + Response.Headers["Content-Range"] = $"bytes {rangedStream.RangeStart}-{rangedStream.RangeEnd}/{rangedStream.TotalSize}"; + Response.Headers["Content-Length"] = rangedStream.ContentLength.ToString(); + Response.Headers["Cache-Control"] = "public, max-age=3600"; + Response.Headers["ETag"] = $"\"{storageKey}\""; + + // CORS headers for video playback + Response.Headers["Access-Control-Allow-Origin"] = "*"; + Response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"; + Response.Headers["Access-Control-Allow-Headers"] = "Range"; + + return File(rangedStream.Stream, rangedStream.ContentType); } /// /// Parses HTTP range header. /// - private (long Start, long End)? ParseRangeHeader(string rangeHeader, long totalSize) + private static (long Start, long End)? ParseRangeHeader(string rangeHeader, long totalSize) { try { diff --git a/Services/ConduitLLM.Gateway/Controllers/ModelsController.cs b/Services/ConduitLLM.Gateway/Controllers/ModelsController.cs index b13b56a3f..daae8042e 100644 --- a/Services/ConduitLLM.Gateway/Controllers/ModelsController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/ModelsController.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Controllers; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Services; @@ -14,9 +15,8 @@ namespace ConduitLLM.Gateway.Controllers [Route("v1")] [Authorize(Policy = "VirtualKeyAuthentication")] [Tags("Models")] - public class ModelsController : ControllerBase + public class ModelsController : GatewayControllerBase { - private readonly ILogger _logger; private readonly IModelMetadataService _metadataService; private readonly ConduitLLM.Configuration.Interfaces.IModelProviderMappingRepository _modelMappingRepository; @@ -24,8 +24,8 @@ public ModelsController( ILogger logger, IModelMetadataService metadataService, ConduitLLM.Configuration.Interfaces.IModelProviderMappingRepository modelMappingRepository) + : base(logger) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _metadataService = metadataService ?? throw new ArgumentNullException(nameof(metadataService)); _modelMappingRepository = modelMappingRepository ?? throw new ArgumentNullException(nameof(modelMappingRepository)); } @@ -33,51 +33,61 @@ public ModelsController( /// /// Lists available models. /// - /// A list of available models. + /// A list of available models in OpenAI-compatible format. + /// + /// This endpoint maintains OpenAI API compatibility and returns all models without pagination. + /// For large deployments with many models, use the Admin API's paginated endpoints. + /// [HttpGet("models")] [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] [ProducesResponseType(typeof(OpenAIErrorResponse), StatusCodes.Status500InternalServerError)] - public async Task ListModels() + public Task ListModels(CancellationToken cancellationToken = default) { - try - { - _logger.LogInformation("Getting available models"); - - // Get model mappings from the repository - var mappings = await _modelMappingRepository.GetAllAsync(); - - // Convert to OpenAI format using model aliases - var basicModelData = mappings - .Select(m => m.ModelAlias) - .Distinct() - .Select(alias => new - { - id = alias, - @object = "model" - }).ToList(); - - // Create the response envelope - var response = new - { - data = basicModelData, - @object = "list" - }; - - return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving models list"); - return StatusCode(500, new OpenAIErrorResponse + return ExecuteAsync( + async () => { - Error = new OpenAIError + Logger.LogInformation("Getting available models"); + + // Get model mappings using paginated repository method + // Use max page size; most deployments have <100 model mappings + var allMappings = new List(); + var pageNumber = 1; + const int pageSize = 100; + + // Fetch all pages to maintain OpenAI API compatibility (no pagination in response) + while (true) { - Message = ex.Message, - Type = "server_error", - Code = "internal_error" + var (mappings, totalCount) = await _modelMappingRepository.GetPaginatedAsync(pageNumber, pageSize, cancellationToken); + allMappings.AddRange(mappings); + + if (allMappings.Count >= totalCount || mappings.Count == 0) + break; + + pageNumber++; } - }); - } + + // Convert to OpenAI format using model aliases + var basicModelData = allMappings + .Select(m => m.ModelAlias) + .Distinct() + .Select(alias => new + { + id = alias, + @object = "model" + }).ToList(); + + Logger.LogDebug("Returning {ModelCount} available models", basicModelData.Count); + + // Create the response envelope + var response = new + { + data = basicModelData, + @object = "list" + }; + + return Ok(response); + }, + "ListModels"); } /// @@ -89,48 +99,38 @@ public async Task ListModels() [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(OpenAIErrorResponse), StatusCodes.Status500InternalServerError)] - public async Task GetModelMetadata(string modelId) + public Task GetModelMetadata(string modelId) { - try - { - _logger.LogInformation("Getting metadata for model {ModelId}", modelId); - - var metadata = await _metadataService.GetModelMetadataAsync(modelId); - - if (metadata == null) + return ExecuteAsync( + async () => { - return NotFound(new OpenAIErrorResponse + Logger.LogInformation("Getting metadata for model {ModelId}", modelId); + + var metadata = await _metadataService.GetModelMetadataAsync(modelId); + + if (metadata == null) { - Error = new OpenAIError + return NotFound(new OpenAIErrorResponse { - Message = $"No metadata found for model '{modelId}'", - Type = "invalid_request_error", - Code = "model_not_found" - } - }); - } - - var response = new - { - modelId = modelId, - metadata = metadata - }; - - return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving metadata for model {ModelId}", modelId); - return StatusCode(500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = ex.Message, - Type = "server_error", - Code = "internal_error" + Error = new OpenAIError + { + Message = $"No metadata found for model '{modelId}'", + Type = "invalid_request_error", + Code = "model_not_found" + } + }); } - }); - } + + var response = new + { + modelId = modelId, + metadata = metadata + }; + + return Ok(response); + }, + "GetModelMetadata", + modelId); } } } diff --git a/Services/ConduitLLM.Gateway/Controllers/ProviderModelsController.cs b/Services/ConduitLLM.Gateway/Controllers/ProviderModelsController.cs index 2830bfe7b..b7b62391e 100644 --- a/Services/ConduitLLM.Gateway/Controllers/ProviderModelsController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/ProviderModelsController.cs @@ -1,7 +1,8 @@ using ConduitLLM.Configuration; +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Models; using Microsoft.AspNetCore.Mvc; -using ConduitLLM.Configuration.DTOs; using Microsoft.EntityFrameworkCore; namespace ConduitLLM.Gateway.Controllers @@ -11,10 +12,9 @@ namespace ConduitLLM.Gateway.Controllers /// [ApiController] [Route("api/provider-models")] - public class ProviderModelsController : ControllerBase + public class ProviderModelsController : GatewayControllerBase { private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -24,9 +24,9 @@ public class ProviderModelsController : ControllerBase public ProviderModelsController( IDbContextFactory dbContextFactory, ILogger logger) + : base(logger) { _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -36,15 +36,15 @@ public ProviderModelsController( /// List of model identifiers that can be used with this provider [HttpGet("{providerId:int}")] [ProducesResponseType(typeof(List), 200)] - [ProducesResponseType(typeof(object), 404)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 404)] public async Task GetProviderModels(int providerId) { - try + return await ExecuteAsync(async () => { - _logger.LogInformation("Getting compatible models for provider {ProviderId}", providerId); + Logger.LogInformation("Getting compatible models for provider {ProviderId}", providerId); await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - + // Get the provider to determine its type var provider = await dbContext.Providers .AsNoTracking() @@ -52,8 +52,16 @@ public async Task GetProviderModels(int providerId) if (provider == null) { - _logger.LogWarning("Provider with ID {ProviderId} not found", providerId); - return NotFound(new ErrorResponseDto($"Provider with ID {providerId} not found")); + Logger.LogWarning("Provider with ID {ProviderId} not found", providerId); + return NotFound(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = $"Provider with ID {providerId} not found", + Type = "not_found_error", + Code = "not_found" + } + }); } // Get all models that have the appropriate capabilities for this provider type @@ -67,19 +75,19 @@ public async Task GetProviderModels(int providerId) { case ProviderType.OpenAI: case ProviderType.OpenAICompatible: - query = query.Where(m => m.SupportsChat || + query = query.Where(m => m.SupportsChat || m.SupportsImageGeneration || m.SupportsEmbeddings); break; - + case ProviderType.Replicate: // Replicate supports various model types including video - query = query.Where(m => m.SupportsImageGeneration || + query = query.Where(m => m.SupportsImageGeneration || m.SupportsVideoGeneration || m.SupportsChat); break; - - + + case ProviderType.Groq: case ProviderType.Cerebras: case ProviderType.SambaNova: @@ -87,7 +95,7 @@ public async Task GetProviderModels(int providerId) // Fast inference providers typically support chat models query = query.Where(m => m.SupportsChat); break; - + default: // For other providers, return all active models break; @@ -100,17 +108,17 @@ public async Task GetProviderModels(int providerId) // Get the model identifiers that are most commonly used // Prefer identifiers that match the provider type if available var modelIdentifiers = new List(); - + // Map provider type to enum for comparison var providerType = provider.ProviderType; - + foreach (var model in models) { // First, check if there's a provider-specific identifier var providerSpecificId = model.Identifiers - .FirstOrDefault(i => i.Provider.HasValue && + .FirstOrDefault(i => i.Provider.HasValue && i.Provider.Value == providerType); - + if (providerSpecificId != null) { modelIdentifiers.Add(providerSpecificId.Identifier); @@ -142,16 +150,11 @@ public async Task GetProviderModels(int providerId) .OrderBy(m => m, StringComparer.OrdinalIgnoreCase) .ToList(); - _logger.LogInformation("Found {ModelsCount} compatible models for provider {ProviderId} (type: {ProviderType})", + Logger.LogInformation("Found {ModelsCount} compatible models for provider {ProviderId} (type: {ProviderType})", sortedIdentifiers.Count, providerId, provider.ProviderType); return Ok(sortedIdentifiers); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving models for provider {ProviderId}", providerId); - return StatusCode(500, new ErrorResponseDto($"Failed to retrieve models: {ex.Message}")); - } + }, "GetProviderModels", providerId); } } } diff --git a/Services/ConduitLLM.Gateway/Controllers/SignalRBatchingController.cs b/Services/ConduitLLM.Gateway/Controllers/SignalRBatchingController.cs index 93ad0d01b..41d0e4e7e 100644 --- a/Services/ConduitLLM.Gateway/Controllers/SignalRBatchingController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/SignalRBatchingController.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Controllers; using ConduitLLM.Gateway.Services; using Microsoft.AspNetCore.Authorization; @@ -11,17 +12,16 @@ namespace ConduitLLM.Gateway.Controllers [ApiController] [Route("api/signalr/batching")] [Authorize(Policy = "AdminOnly")] - public class SignalRBatchingController : ControllerBase + public class SignalRBatchingController : GatewayControllerBase { private readonly ISignalRMessageBatcher _messageBatcher; - private readonly ILogger _logger; public SignalRBatchingController( ISignalRMessageBatcher messageBatcher, ILogger logger) + : base(logger) { _messageBatcher = messageBatcher; - _logger = logger; } /// @@ -29,9 +29,9 @@ public SignalRBatchingController( /// [HttpGet("statistics")] [AllowAnonymous] - public ActionResult GetStatistics() + public async Task> GetStatistics() { - var stats = _messageBatcher.GetStatistics(); + var stats = await _messageBatcher.GetStatisticsAsync(); return Ok(stats); } @@ -42,7 +42,7 @@ public ActionResult GetStatistics() public ActionResult PauseBatching() { _messageBatcher.PauseBatching(); - _logger.LogInformation("Message batching paused by admin"); + Logger.LogInformation("Message batching paused by admin"); return Ok(new { message = "Batching paused successfully" }); } @@ -53,7 +53,7 @@ public ActionResult PauseBatching() public ActionResult ResumeBatching() { _messageBatcher.ResumeBatching(); - _logger.LogInformation("Message batching resumed by admin"); + Logger.LogInformation("Message batching resumed by admin"); return Ok(new { message = "Batching resumed successfully" }); } @@ -64,7 +64,7 @@ public ActionResult ResumeBatching() public async Task FlushBatches() { await _messageBatcher.FlushAllBatchesAsync(); - _logger.LogInformation("All batches flushed by admin"); + Logger.LogInformation("All batches flushed by admin"); return Ok(new { message = "All batches flushed successfully" }); } @@ -73,9 +73,9 @@ public async Task FlushBatches() /// [HttpGet("efficiency")] [AllowAnonymous] - public ActionResult GetEfficiencyMetrics() + public async Task GetEfficiencyMetrics() { - var stats = _messageBatcher.GetStatistics(); + var stats = await _messageBatcher.GetStatisticsAsync(); return Ok(new { diff --git a/Services/ConduitLLM.Gateway/Controllers/SignalRHealthController.cs b/Services/ConduitLLM.Gateway/Controllers/SignalRHealthController.cs index ac6f7c023..32476b725 100644 --- a/Services/ConduitLLM.Gateway/Controllers/SignalRHealthController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/SignalRHealthController.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Controllers; using ConduitLLM.Gateway.Services; using Microsoft.AspNetCore.Authorization; @@ -6,45 +7,53 @@ namespace ConduitLLM.Gateway.Controllers { /// - /// Controller for SignalR health and monitoring endpoints + /// Controller for SignalR health and monitoring endpoints. + /// Access is controlled by the HealthEndpointAuthorizationMiddleware which allows + /// requests from private networks or with a valid X-Conduit-Health-Key header. /// + /// + /// The middleware handles basic health endpoint authorization (private network or health key). + /// Methods without explicit auth attributes are protected by the middleware. + /// Methods with [Authorize(Policy = "AdminOnly")] require additional backend authentication. + /// [ApiController] [Route("health/signalr")] - public class SignalRHealthController : ControllerBase + public class SignalRHealthController : GatewayControllerBase { private readonly ISignalRConnectionMonitor _connectionMonitor; private readonly ISignalRMessageQueueService _messageQueueService; private readonly ISignalRAcknowledgmentService _acknowledgmentService; - private readonly ILogger _logger; public SignalRHealthController( ISignalRConnectionMonitor connectionMonitor, ISignalRMessageQueueService messageQueueService, ISignalRAcknowledgmentService acknowledgmentService, ILogger logger) + : base(logger) { _connectionMonitor = connectionMonitor; _messageQueueService = messageQueueService; _acknowledgmentService = acknowledgmentService; - _logger = logger; } /// - /// Gets SignalR connection statistics + /// Gets SignalR connection statistics. + /// Access controlled by health endpoint middleware (private network or valid health key). /// [HttpGet("connections")] - [AllowAnonymous] - public ActionResult GetConnectionStatistics() + [AllowAnonymous] // Middleware handles health endpoint authorization + public async Task> GetConnectionStatistics() { - var stats = _connectionMonitor.GetStatistics(); + var stats = await _connectionMonitor.GetStatisticsAsync(); return Ok(stats); } /// - /// Gets SignalR message queue statistics + /// Gets SignalR message queue statistics. + /// Access controlled by health endpoint middleware (private network or valid health key). /// [HttpGet("queue")] - [AllowAnonymous] + [AllowAnonymous] // Middleware handles health endpoint authorization public ActionResult GetQueueStatistics() { var stats = _messageQueueService.GetStatistics(); @@ -56,9 +65,9 @@ public ActionResult GetQueueStatistics() /// [HttpGet("connections/details")] [Authorize(Policy = "AdminOnly")] - public ActionResult GetConnectionDetails() + public async Task> GetConnectionDetails() { - var connections = _connectionMonitor.GetActiveConnections(); + var connections = await _connectionMonitor.GetActiveConnectionsAsync(); return Ok(new { activeConnections = connections, @@ -67,13 +76,14 @@ public ActionResult GetConnectionDetails() } /// - /// Gets connections for a specific hub + /// Gets connections for a specific hub. + /// Access controlled by health endpoint middleware (private network or valid health key). /// [HttpGet("connections/hub/{hubName}")] - [AllowAnonymous] - public ActionResult GetHubConnections(string hubName) + [AllowAnonymous] // Middleware handles health endpoint authorization + public async Task> GetHubConnections(string hubName) { - var connections = _connectionMonitor.GetHubConnections(hubName); + var connections = await _connectionMonitor.GetHubConnectionsAsync(hubName); return Ok(new { hubName, @@ -95,12 +105,12 @@ public ActionResult GetHubConnections(string hubName) /// [HttpGet("connections/key/{virtualKeyId}")] [Authorize] - public ActionResult GetVirtualKeyConnections(int virtualKeyId) + public async Task> GetVirtualKeyConnections(int virtualKeyId) { // Check if the requester has permission to view this virtual key's connections // This would normally involve checking if the requester owns or has admin access to the key - - var connections = _connectionMonitor.GetVirtualKeyConnections(virtualKeyId); + + var connections = await _connectionMonitor.GetVirtualKeyConnectionsAsync(virtualKeyId); return Ok(new { virtualKeyId, @@ -117,13 +127,14 @@ public ActionResult GetVirtualKeyConnections(int virtualKeyId) } /// - /// Gets connections in a specific group + /// Gets connections in a specific group. + /// Access controlled by health endpoint middleware (private network or valid health key). /// [HttpGet("connections/group/{groupName}")] - [AllowAnonymous] - public ActionResult GetGroupConnections(string groupName) + [AllowAnonymous] // Middleware handles health endpoint authorization + public async Task> GetGroupConnections(string groupName) { - var connections = _connectionMonitor.GetGroupConnections(groupName); + var connections = await _connectionMonitor.GetGroupConnectionsAsync(groupName); return Ok(new { groupName, @@ -171,17 +182,19 @@ public ActionResult GetDeadLetterMessages() public async Task RequeueDeadLetter(string messageId) { await _messageQueueService.RequeueDeadLetterAsync(messageId); - _logger.LogInformation("Dead letter message {MessageId} requeued by admin", messageId); + Logger.LogInformation("Dead letter message {MessageId} requeued by admin", messageId); return Ok(new { message = "Message requeued successfully" }); } /// - /// Gets overall SignalR health status + /// Gets overall SignalR health status. + /// Access controlled by health endpoint middleware (private network or valid health key). /// [HttpGet] - public ActionResult GetHealthStatus() + [AllowAnonymous] // Middleware handles health endpoint authorization + public async Task> GetHealthStatus() { - var connectionStats = _connectionMonitor.GetStatistics(); + var connectionStats = await _connectionMonitor.GetStatisticsAsync(); var queueStats = _messageQueueService.GetStatistics(); var isHealthy = connectionStats.TotalActiveConnections >= 0 && diff --git a/Services/ConduitLLM.Gateway/Controllers/TasksController.cs b/Services/ConduitLLM.Gateway/Controllers/TasksController.cs index 6cf52881e..fc5b94cf5 100644 --- a/Services/ConduitLLM.Gateway/Controllers/TasksController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/TasksController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using ConduitLLM.Core.Interfaces; -using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Models; using Microsoft.AspNetCore.Authorization; namespace ConduitLLM.Gateway.Controllers @@ -11,10 +12,9 @@ namespace ConduitLLM.Gateway.Controllers [ApiController] [Route("v1/tasks")] [Authorize] - public class TasksController : ControllerBase + public class TasksController : GatewayControllerBase { private readonly IAsyncTaskService _taskService; - private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -22,9 +22,9 @@ public class TasksController : ControllerBase /// The async task service. /// The logger. public TasksController(IAsyncTaskService taskService, ILogger logger) + : base(logger) { _taskService = taskService ?? throw new ArgumentNullException(nameof(taskService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -35,20 +35,27 @@ public TasksController(IAsyncTaskService taskService, ILogger l [HttpGet("{taskId}")] public async Task GetTaskStatus(string taskId) { - try + return await ExecuteAsync(async () => { - var status = await _taskService.GetTaskStatusAsync(taskId); - return Ok(status); - } - catch (InvalidOperationException ex) - { - return NotFound(new ErrorResponseDto(new ErrorDetailsDto(ex.Message, "not_found"))); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving task {TaskId}", taskId); - return StatusCode(500, new ErrorResponseDto(new ErrorDetailsDto("An error occurred while retrieving the task", "server_error"))); - } + Logger.LogDebug("Getting status for task {TaskId}", taskId); + try + { + var status = await _taskService.GetTaskStatusAsync(taskId); + return Ok(status); + } + catch (InvalidOperationException ex) + { + return NotFound(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = ex.Message, + Type = "not_found_error", + Code = "not_found" + } + }); + } + }, "GetTaskStatus", taskId); } /// @@ -59,20 +66,27 @@ public async Task GetTaskStatus(string taskId) [HttpPost("{taskId}/cancel")] public async Task CancelTask(string taskId) { - try - { - await _taskService.CancelTaskAsync(taskId); - return NoContent(); - } - catch (InvalidOperationException ex) - { - return NotFound(new ErrorResponseDto(new ErrorDetailsDto(ex.Message, "not_found"))); - } - catch (Exception ex) + return await ExecuteAsync(async () => { - _logger.LogError(ex, "Error cancelling task {TaskId}", taskId); - return StatusCode(500, new ErrorResponseDto(new ErrorDetailsDto("An error occurred while cancelling the task", "server_error"))); - } + try + { + await _taskService.CancelTaskAsync(taskId); + Logger.LogInformation("Task {TaskId} cancelled successfully", taskId); + return NoContent(); + } + catch (InvalidOperationException ex) + { + return NotFound(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = ex.Message, + Type = "not_found_error", + Code = "not_found" + } + }); + } + }, "CancelTask", taskId); } /// @@ -85,32 +99,49 @@ public async Task CancelTask(string taskId) [HttpGet("{taskId}/poll")] public async Task PollTask(string taskId, [FromQuery] int timeout = 300, [FromQuery] int interval = 2) { - try + return await ExecuteAsync(async () => { // Validate and clamp parameters timeout = Math.Clamp(timeout, 1, 600); // Max 10 minutes interval = Math.Max(interval, 1); // Min 1 second - var status = await _taskService.PollTaskUntilCompletedAsync( - taskId, - TimeSpan.FromSeconds(interval), - TimeSpan.FromSeconds(timeout)); + Logger.LogDebug("Polling task {TaskId} with timeout {TimeoutSeconds}s, interval {IntervalSeconds}s", + taskId, timeout, interval); - return Ok(status); - } - catch (InvalidOperationException ex) - { - return NotFound(new ErrorResponseDto(new ErrorDetailsDto(ex.Message, "not_found"))); - } - catch (OperationCanceledException) - { - return StatusCode(408, new ErrorResponseDto(new ErrorDetailsDto("Task polling timed out", "timeout"))); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error polling task {TaskId}", taskId); - return StatusCode(500, new ErrorResponseDto(new ErrorDetailsDto("An error occurred while polling the task", "server_error"))); - } + try + { + var status = await _taskService.PollTaskUntilCompletedAsync( + taskId, + TimeSpan.FromSeconds(interval), + TimeSpan.FromSeconds(timeout)); + + return Ok(status); + } + catch (InvalidOperationException ex) + { + return NotFound(new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = ex.Message, + Type = "not_found_error", + Code = "not_found" + } + }); + } + catch (OperationCanceledException) + { + return StatusCode(408, new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = "Task polling timed out", + Type = "timeout", + Code = "timeout" + } + }); + } + }, "PollTask", taskId); } } diff --git a/Services/ConduitLLM.Gateway/Controllers/VideosController.cs b/Services/ConduitLLM.Gateway/Controllers/VideosController.cs index f0a4c475a..6db9d48e8 100644 --- a/Services/ConduitLLM.Gateway/Controllers/VideosController.cs +++ b/Services/ConduitLLM.Gateway/Controllers/VideosController.cs @@ -2,9 +2,12 @@ using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; +using MassTransit; using ConduitLLM.Gateway.Authorization; using ConduitLLM.Gateway.Constants; +using ConduitLLM.Gateway.UsageTracking; +using ConduitLLM.Core.Controllers; +using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; using ConduitLLM.Core.Constants; @@ -18,78 +21,71 @@ namespace ConduitLLM.Gateway.Controllers [Route("v1/videos")] [Authorize(AuthenticationSchemes = "VirtualKey")] [RequireBalance] - [EnableRateLimiting("VirtualKeyPolicy")] [Tags("Videos")] - public class VideosController : ControllerBase + public class VideosController : GatewayControllerBase { - private readonly IVideoGenerationService _videoService; private readonly IAsyncTaskService _taskService; private readonly IOperationTimeoutProvider _timeoutProvider; private readonly ICancellableTaskRegistry _taskRegistry; - private readonly ILogger _logger; private readonly ConduitLLM.Configuration.Interfaces.IModelProviderMappingService _modelMappingService; /// /// Initializes a new instance of the class. /// public VideosController( - IVideoGenerationService videoService, IAsyncTaskService taskService, IOperationTimeoutProvider timeoutProvider, ICancellableTaskRegistry taskRegistry, ILogger logger, - ConduitLLM.Configuration.Interfaces.IModelProviderMappingService modelMappingService) + ConduitLLM.Configuration.Interfaces.IModelProviderMappingService modelMappingService, + IPublishEndpoint publishEndpoint) + : base(publishEndpoint, logger) { - _videoService = videoService ?? throw new ArgumentNullException(nameof(videoService)); _taskService = taskService ?? throw new ArgumentNullException(nameof(taskService)); _timeoutProvider = timeoutProvider ?? throw new ArgumentNullException(nameof(timeoutProvider)); _taskRegistry = taskRegistry ?? throw new ArgumentNullException(nameof(taskRegistry)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _modelMappingService = modelMappingService ?? throw new ArgumentNullException(nameof(modelMappingService)); } /// /// Starts an asynchronous video generation task. /// - /// The video generation request. - /// Cancellation token. - /// Task information including task ID for status checking. - /// Video generation task started. - /// Invalid request parameters. - /// Authentication failed. - /// Virtual key does not have permission. - /// Rate limit exceeded. - /// Internal server error. [HttpPost("generations/async")] [ProducesResponseType(typeof(VideoGenerationTaskResponse), 202)] - [ProducesResponseType(typeof(ProblemDetails), 400)] - [ProducesResponseType(typeof(ProblemDetails), 401)] - [ProducesResponseType(typeof(ProblemDetails), 403)] - [ProducesResponseType(typeof(ProblemDetails), 429)] - [ProducesResponseType(typeof(ProblemDetails), 500)] - public async Task GenerateVideoAsync( + [ProducesResponseType(typeof(OpenAIErrorResponse), 400)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 401)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 403)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 429)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 500)] + public Task GenerateVideoAsync( [FromBody][Required] VideoGenerationRequest request, CancellationToken cancellationToken = default) { - try + return ExecuteAsync(async () => { - // Validate request - if (!ModelState.IsValid) + var virtualKey = CurrentVirtualKey; + if (string.IsNullOrEmpty(virtualKey) || CurrentVirtualKeyId == null) { - return BadRequest(ModelState); + return OpenAIError(401, "Virtual key not found in request context", "unauthorized"); } + var virtualKeyId = CurrentVirtualKeyId.Value; - // Get virtual key and ID from HttpContext (set by VirtualKeyAuthenticationMiddleware) - var virtualKey = HttpContext.Items["VirtualKey"]?.ToString(); - var virtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - - if (string.IsNullOrEmpty(virtualKey) || string.IsNullOrEmpty(virtualKeyIdClaim) || !int.TryParse(virtualKeyIdClaim, out int virtualKeyId)) + // Validate the request + if (string.IsNullOrWhiteSpace(request.Prompt)) { - return Unauthorized(new ProblemDetails - { - Title = "Unauthorized", - Detail = "Virtual key not found in request context" - }); + return OpenAIError(400, "Prompt is required", "missing_parameter"); + } + if (string.IsNullOrWhiteSpace(request.Model)) + { + return OpenAIError(400, "Model is required", "missing_parameter"); + } + if (request.Duration.HasValue && (request.Duration.Value < 1 || request.Duration.Value > 60)) + { + return OpenAIError(400, "Duration must be between 1 and 60 seconds", "invalid_value"); + } + if (request.Fps.HasValue && (request.Fps.Value < 1 || request.Fps.Value > 120)) + { + return OpenAIError(400, "FPS must be between 1 and 120", "invalid_value"); } // Store video request parameters for usage tracking and pricing @@ -113,136 +109,111 @@ public async Task GenerateVideoAsync( } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get provider info for model {Model}", request.Model); + Logger.LogWarning(ex, "Failed to get provider info for model {Model}", request.Model); } // Create a linked cancellation token that can be controlled independently using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - var response = await _videoService.GenerateVideoWithTaskAsync( - request, - virtualKey, - cts.Token); - - // Extract task ID from the response - var taskId = response.Data?.FirstOrDefault()?.Url?.Replace("pending:", ""); - if (string.IsNullOrEmpty(taskId)) + + // Build task metadata. The orchestrator reads ExtensionData["VirtualKey"] for re-validation + // and ExtensionData["Request"] to reconstruct the original request when consuming the event. + var taskMetadata = new TaskMetadata(virtualKeyId) { - throw new InvalidOperationException("Failed to create video generation task"); - } - + Model = request.Model, + Prompt = request.Prompt, + ExtensionData = new Dictionary + { + ["VirtualKey"] = virtualKey, + ["Request"] = request + } + }; + + var taskId = await _taskService.CreateTaskAsync("video_generation", virtualKeyId, taskMetadata, cts.Token); + // Register the task for cancellation _taskRegistry.RegisterTask(taskId, cts); - _logger.LogDebug("Registered task {TaskId} for cancellation", taskId); - // Create task response - // Note: Client will use ephemeral keys for SignalR authentication + // Convert ExtensionData to provider options for the event payload + Dictionary? providerOptions = null; + if (request.ExtensionData != null && request.ExtensionData.Count > 0) + { + providerOptions = new Dictionary(); + foreach (var kvp in request.ExtensionData) + { + providerOptions[kvp.Key] = kvp.Value.ToString(); + } + } + + PublishEventFireAndForget(new VideoGenerationRequested + { + RequestId = taskId, + Model = request.Model, + Prompt = request.Prompt, + VirtualKeyId = virtualKeyId.ToString(), + IsAsync = true, + RequestedAt = DateTime.UtcNow, + CorrelationId = taskId, + WebhookUrl = request.WebhookUrl, + WebhookHeaders = request.WebhookHeaders, + Parameters = new VideoGenerationParameters + { + Size = request.Size, + Duration = request.Duration, + Fps = request.Fps, + Style = request.Style, + ResponseFormat = request.ResponseFormat, + ProviderOptions = providerOptions + } + }, "create async video generation", new { TaskId = taskId, Model = request.Model }); + var taskResponse = new VideoGenerationTaskResponse { TaskId = taskId, Status = TaskStateConstants.Pending, CreatedAt = DateTimeOffset.UtcNow, - EstimatedCompletionTime = DateTimeOffset.UtcNow.AddSeconds(60), // Default estimate + EstimatedCompletionTime = DateTimeOffset.UtcNow.AddSeconds(60), CheckStatusUrl = $"/v1/videos/generations/tasks/{taskId}" - // SignalRToken removed - clients will use ephemeral keys }; return Accepted(taskResponse); - } - catch (ArgumentException ex) - { - _logger.LogWarning(ex, "Invalid async video generation request"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Request", - Detail = ex.Message - }); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Unauthorized async video generation attempt"); - return StatusCode(403, new ProblemDetails - { - Title = "Forbidden", - Detail = ex.Message - }); - } - catch (NotSupportedException ex) - { - _logger.LogWarning(ex, "Unsupported model or feature for async generation"); - return BadRequest(new ProblemDetails - { - Title = "Not Supported", - Detail = ex.Message - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting async video generation"); - return StatusCode(500, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while starting video generation" - }); - } + }, + "GenerateVideoAsync", + request.Model); } /// /// Gets the status of a video generation task. /// - /// The task ID returned from the async generation endpoint. - /// Cancellation token. - /// Current status of the video generation task. - /// Task status retrieved successfully. - /// Authentication failed. - /// Task not found or access denied. - /// Internal server error. [HttpGet("generations/tasks/{taskId}")] [ProducesResponseType(typeof(VideoGenerationTaskStatus), 200)] - [ProducesResponseType(typeof(ProblemDetails), 401)] - [ProducesResponseType(typeof(ProblemDetails), 404)] - [ProducesResponseType(typeof(ProblemDetails), 500)] - public async Task GetTaskStatus( + [ProducesResponseType(typeof(OpenAIErrorResponse), 401)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 404)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 500)] + public Task GetTaskStatus( [FromRoute][Required] string taskId, CancellationToken cancellationToken = default) { - try + return ExecuteAsync(async () => { - // Get virtual key ID from claims (set by VirtualKeyAuthenticationMiddleware) - var virtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - if (string.IsNullOrEmpty(virtualKeyIdClaim) || !int.TryParse(virtualKeyIdClaim, out int virtualKeyId)) + if (CurrentVirtualKeyId == null) { - return Unauthorized(new ProblemDetails - { - Title = "Unauthorized", - Detail = "Virtual key not found in request context" - }); + return OpenAIError(401, "Virtual key not found in request context", "unauthorized"); } + var virtualKeyId = CurrentVirtualKeyId.Value; var taskStatus = await _taskService.GetTaskStatusAsync(taskId, cancellationToken); if (taskStatus == null) { - return NotFound(new ProblemDetails - { - Title = "Task Not Found", - Detail = "The requested task was not found" - }); + return OpenAIError(404, "The requested task was not found", "not_found"); } - // TODO: Consolidate security validation with ImagesController - // Video uses simple VirtualKeyId comparison, Images uses hash-based validation - // Both approaches are secure but inconsistent - should standardize on one approach // Validate task ownership for security if (taskStatus.Metadata?.VirtualKeyId != virtualKeyId) { // Return 404 instead of 403 to prevent information disclosure - _logger.LogWarning("Virtual key {VirtualKeyId} attempted to access task {TaskId} owned by {OwnerKeyId}", + Logger.LogWarning("Virtual key {VirtualKeyId} attempted to access task {TaskId} owned by {OwnerKeyId}", virtualKeyId, taskId, taskStatus.Metadata?.VirtualKeyId); - return NotFound(new ProblemDetails - { - Title = "Task Not Found", - Detail = "The requested task was not found" - }); + return OpenAIError(404, "The requested task was not found", "not_found"); } // Map internal task status to API response @@ -255,131 +226,70 @@ public async Task GetTaskStatus( UpdatedAt = taskStatus.UpdatedAt, CompletedAt = taskStatus.CompletedAt, Error = taskStatus.Error, - Result = taskStatus.Result?.ToString() + ResultRaw = taskStatus.Result?.ToString() }; - // If completed, try to get the video response - if (taskStatus.State == TaskState.Completed && !string.IsNullOrEmpty(taskStatus.Result?.ToString())) + // If completed, deserialize the stored result into a typed VideoGenerationResponse. + if (taskStatus.State == TaskState.Completed && taskStatus.Result != null) { - try - { - // Get virtual key string for the video service call - var virtualKey = HttpContext.Items["VirtualKey"]?.ToString(); - if (!string.IsNullOrEmpty(virtualKey)) - { - var videoResponse = await _videoService.GetVideoGenerationStatusAsync( - taskId, - virtualKey, - cancellationToken); - response.VideoResponse = videoResponse; - } - } - catch (NotImplementedException) - { - // Status tracking not yet implemented, just return basic status - } + response.Result = DeserializeVideoResult(taskStatus.Result, taskId); } return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving task status for {TaskId}", taskId); - return StatusCode(500, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while retrieving task status" - }); - } + }, + "GetTaskStatus", + taskId); } /// /// Manually retries a failed video generation task. /// - /// The task ID to retry. - /// Cancellation token. - /// Updated task status. - /// Task queued for retry. - /// Task cannot be retried (not failed or exceeded max retries). - /// Authentication failed. - /// Task not found or access denied. - /// Internal server error. [HttpPost("generations/tasks/{taskId}/retry")] [ProducesResponseType(typeof(VideoGenerationTaskStatus), 200)] - [ProducesResponseType(typeof(ProblemDetails), 400)] - [ProducesResponseType(typeof(ProblemDetails), 401)] - [ProducesResponseType(typeof(ProblemDetails), 404)] - [ProducesResponseType(typeof(ProblemDetails), 500)] - public async Task RetryTask( + [ProducesResponseType(typeof(OpenAIErrorResponse), 400)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 401)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 404)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 500)] + public Task RetryTask( [FromRoute][Required] string taskId, CancellationToken cancellationToken = default) { - try + return ExecuteAsync(async () => { - // Get virtual key ID from claims (set by VirtualKeyAuthenticationMiddleware) - var virtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - if (string.IsNullOrEmpty(virtualKeyIdClaim) || !int.TryParse(virtualKeyIdClaim, out int virtualKeyId)) + if (CurrentVirtualKeyId == null) { - return Unauthorized(new ProblemDetails - { - Title = "Unauthorized", - Detail = "Virtual key not found in request context" - }); + return OpenAIError(401, "Virtual key not found in request context", "unauthorized"); } + var virtualKeyId = CurrentVirtualKeyId.Value; - // Get current task status var taskStatus = await _taskService.GetTaskStatusAsync(taskId, cancellationToken); if (taskStatus == null) { - return NotFound(new ProblemDetails - { - Title = "Task Not Found", - Detail = "The requested task was not found" - }); + return OpenAIError(404, "The requested task was not found", "not_found"); } - // TODO: Consolidate security validation with ImagesController - // Video uses simple VirtualKeyId comparison, Images uses hash-based validation - // Both approaches are secure but inconsistent - should standardize on one approach // Validate task ownership for security if (taskStatus.Metadata?.VirtualKeyId != virtualKeyId) { - // Return 404 instead of 403 to prevent information disclosure - _logger.LogWarning("Virtual key {VirtualKeyId} attempted to retry task {TaskId} owned by {OwnerKeyId}", + Logger.LogWarning("Virtual key {VirtualKeyId} attempted to retry task {TaskId} owned by {OwnerKeyId}", virtualKeyId, taskId, taskStatus.Metadata?.VirtualKeyId); - return NotFound(new ProblemDetails - { - Title = "Task Not Found", - Detail = "The requested task was not found" - }); + return OpenAIError(404, "The requested task was not found", "not_found"); } // Validate task can be retried if (taskStatus.State != TaskState.Failed) { - return BadRequest(new ProblemDetails - { - Title = "Invalid Task State", - Detail = $"Only failed tasks can be retried. Current state: {taskStatus.State}" - }); + return OpenAIError(400, $"Only failed tasks can be retried. Current state: {taskStatus.State}", "invalid_operation"); } if (!taskStatus.IsRetryable) { - return BadRequest(new ProblemDetails - { - Title = "Task Not Retryable", - Detail = "This task has been marked as non-retryable" - }); + return OpenAIError(400, "This task has been marked as non-retryable", "invalid_operation"); } if (taskStatus.RetryCount >= taskStatus.MaxRetries) { - return BadRequest(new ProblemDetails - { - Title = "Max Retries Exceeded", - Detail = $"Task has already been retried {taskStatus.RetryCount} times (max: {taskStatus.MaxRetries})" - }); + return OpenAIError(400, $"Task has already been retried {taskStatus.RetryCount} times (max: {taskStatus.MaxRetries})", "invalid_operation"); } // Reset task for retry @@ -389,7 +299,7 @@ await _taskService.UpdateTaskStatusAsync( error: $"Manual retry requested (attempt {taskStatus.RetryCount + 1}/{taskStatus.MaxRetries})", cancellationToken: cancellationToken); - _logger.LogInformation("Manual retry requested for task {TaskId} by virtual key {VirtualKeyId}", + Logger.LogInformation("Manual retry requested for task {TaskId} by virtual key {VirtualKeyId}", taskId, virtualKeyId); // Return updated status @@ -405,191 +315,130 @@ await _taskService.UpdateTaskStatusAsync( }; return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrying task {TaskId}", taskId); - return StatusCode(500, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while retrying the task" - }); - } + }, + "RetryTask", + taskId); } /// /// Cancels a video generation task. /// - /// The task ID to cancel. - /// Cancellation token. - /// Cancellation result. - /// Task cancelled successfully. - /// Authentication failed. - /// Task not found or access denied. - /// Task cannot be cancelled (already completed or failed). - /// Internal server error. [HttpDelete("generations/{taskId}")] [ProducesResponseType(204)] - [ProducesResponseType(typeof(ProblemDetails), 401)] - [ProducesResponseType(typeof(ProblemDetails), 404)] - [ProducesResponseType(typeof(ProblemDetails), 409)] - [ProducesResponseType(typeof(ProblemDetails), 500)] - public async Task CancelTask( + [ProducesResponseType(typeof(OpenAIErrorResponse), 401)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 404)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 409)] + [ProducesResponseType(typeof(OpenAIErrorResponse), 500)] + public Task CancelTask( [FromRoute][Required] string taskId, CancellationToken cancellationToken = default) { - try + return ExecuteAsync(async () => { - // Get virtual key ID from claims (set by VirtualKeyAuthenticationMiddleware) - var virtualKeyIdClaim = HttpContext.User.FindFirst("VirtualKeyId")?.Value; - if (string.IsNullOrEmpty(virtualKeyIdClaim) || !int.TryParse(virtualKeyIdClaim, out int virtualKeyId)) + if (CurrentVirtualKeyId == null) { - return Unauthorized(new ProblemDetails - { - Title = "Unauthorized", - Detail = "Virtual key not found in request context" - }); + return OpenAIError(401, "Virtual key not found in request context", "unauthorized"); } + var virtualKeyId = CurrentVirtualKeyId.Value; - // Check if task exists var taskStatus = await _taskService.GetTaskStatusAsync(taskId, cancellationToken); if (taskStatus == null) { - return NotFound(new ProblemDetails - { - Title = "Task Not Found", - Detail = "The requested task was not found" - }); + return OpenAIError(404, "The requested task was not found", "not_found"); } - // TODO: Consolidate security validation with ImagesController - // Video uses simple VirtualKeyId comparison, Images uses hash-based validation - // Both approaches are secure but inconsistent - should standardize on one approach // Validate task ownership for security if (taskStatus.Metadata?.VirtualKeyId != virtualKeyId) { - // Return 404 instead of 403 to prevent information disclosure - _logger.LogWarning("Virtual key {VirtualKeyId} attempted to cancel task {TaskId} owned by {OwnerKeyId}", + Logger.LogWarning("Virtual key {VirtualKeyId} attempted to cancel task {TaskId} owned by {OwnerKeyId}", virtualKeyId, taskId, taskStatus.Metadata?.VirtualKeyId); - return NotFound(new ProblemDetails - { - Title = "Task Not Found", - Detail = "The requested task was not found" - }); + return OpenAIError(404, "The requested task was not found", "not_found"); } // Check if task can be cancelled if (taskStatus.State == TaskState.Completed || taskStatus.State == TaskState.Failed) { - return Conflict(new ProblemDetails - { - Title = "Cannot Cancel Task", - Detail = $"Task is already {taskStatus.State.ToString().ToLowerInvariant()} and cannot be cancelled" - }); + return OpenAIError(409, $"Task is already {taskStatus.State.ToString().ToLowerInvariant()} and cannot be cancelled", "invalid_operation"); } - // Try to cancel via the registry first + // Try to cancel via the registry first (signals any in-flight provider call) var registryCancelled = _taskRegistry.TryCancel(taskId); if (registryCancelled) { - _logger.LogInformation("Cancelled task {TaskId} via registry", taskId); + Logger.LogInformation("Cancelled task {TaskId} via registry", taskId); } - - // Also notify the video service - var virtualKey = HttpContext.Items["VirtualKey"]?.ToString(); - var cancelled = await _videoService.CancelVideoGenerationAsync( - taskId, - virtualKey ?? string.Empty, - cancellationToken); - if (cancelled || registryCancelled) + // Mark the task as cancelled and notify consumers via event + await _taskService.CancelTaskAsync(taskId, cancellationToken); + + PublishEventFireAndForget(new VideoGenerationCancelled { - // Update task status to cancelled - await _taskService.CancelTaskAsync(taskId, cancellationToken); - return NoContent(); - } - else + RequestId = taskId, + CancelledAt = DateTime.UtcNow, + CorrelationId = taskId, + Reason = "User requested cancellation" + }, "cancel video generation", new { TaskId = taskId }); + + return NoContent(); + }, + "CancelTask", + taskId); + } + + /// + /// Deserializes the stored task result into a typed . + /// The result may already be a typed object (in-memory task store) or a JsonElement (Redis/DB). + /// Returns null if deserialization fails โ€” caller falls back to ResultRaw. + /// + private VideoGenerationResponse? DeserializeVideoResult(object result, string taskId) + { + try + { + if (result is VideoGenerationResponse typed) { - return Conflict(new ProblemDetails - { - Title = "Cancellation Failed", - Detail = "Unable to cancel the video generation task" - }); + return typed; } + var json = result is JsonElement element + ? element.GetRawText() + : JsonSerializer.Serialize(result); + return JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch (Exception ex) { - _logger.LogError(ex, "Error cancelling task {TaskId}", taskId); - return StatusCode(500, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while cancelling the task" - }); + Logger.LogWarning(ex, "Failed to deserialize video task result for {TaskId}", taskId); + return null; } } /// - /// Stores video request parameters in HttpContext.Items for usage tracking and pricing. - /// Extracts standard parameters (size, duration, fps) and builds pricing parameters - /// from ExtensionData for rules-based pricing evaluation. + /// Stores video request parameters in a typed for + /// the usage tracking middleware (cost calculation, request logging, pricing rules). /// private void StoreVideoRequestParameters(VideoGenerationRequest request) { - // Store standard request parameters - HttpContext.Items[HttpContextKeys.VideoRequestModel] = request.Model; - HttpContext.Items[HttpContextKeys.VideoRequestN] = request.N; - - if (!string.IsNullOrEmpty(request.Size)) - { - HttpContext.Items[HttpContextKeys.VideoRequestSize] = request.Size; - } - - if (request.Duration.HasValue) - { - HttpContext.Items[HttpContextKeys.VideoRequestDuration] = request.Duration.Value; - } - - if (request.Fps.HasValue) - { - HttpContext.Items[HttpContextKeys.VideoRequestFps] = request.Fps.Value; - } - - if (!string.IsNullOrEmpty(request.Style)) - { - HttpContext.Items[HttpContextKeys.VideoRequestStyle] = request.Style; - } - // Build pricing parameters dictionary for rules-based pricing var pricingParameters = new Dictionary(); - // Add resolution (normalized to common format like "1080p") if (!string.IsNullOrEmpty(request.Size)) { pricingParameters["resolution"] = NormalizeResolution(request.Size); } - - // Add duration if specified if (request.Duration.HasValue) { pricingParameters["duration"] = request.Duration.Value; } - - // Add FPS if specified if (request.Fps.HasValue) { pricingParameters["fps"] = request.Fps.Value; } - - // Add style if specified if (!string.IsNullOrEmpty(request.Style)) { pricingParameters["style"] = request.Style; } - // Extract additional pricing parameters from ExtensionData if (request.ExtensionData != null) { - // Common pricing-relevant parameters from various video providers ExtractExtensionParameter(request.ExtensionData, "with_audio", pricingParameters); ExtractExtensionParameter(request.ExtensionData, "audio", pricingParameters); ExtractExtensionParameter(request.ExtensionData, "aspect_ratio", pricingParameters); @@ -599,10 +448,19 @@ private void StoreVideoRequestParameters(VideoGenerationRequest request) ExtractExtensionParameter(request.ExtensionData, "motion_bucket_id", pricingParameters); } - HttpContext.Items[HttpContextKeys.VideoRequestPricingParameters] = pricingParameters; - - _logger.LogDebug( - "Stored video request parameters: Model={Model}, Size={Size}, Duration={Duration}, N={N}, PricingParams={PricingParamsCount}", + HttpContext.SetUsageContext(new VideoUsageContext + { + Model = request.Model, + Size = string.IsNullOrEmpty(request.Size) ? null : request.Size, + Duration = request.Duration, + Fps = request.Fps, + Style = string.IsNullOrEmpty(request.Style) ? null : request.Style, + N = request.N, + PricingParameters = pricingParameters.Count > 0 ? pricingParameters : null + }); + + Logger.LogDebug( + "Stored video usage context: Model={Model}, Size={Size}, Duration={Duration}, N={N}, PricingParams={PricingParamsCount}", request.Model, request.Size, request.Duration, request.N, pricingParameters.Count); } @@ -641,11 +499,9 @@ private static string NormalizeResolution(string resolution) if (string.IsNullOrEmpty(resolution)) return resolution; - // Already normalized format if (resolution.EndsWith("p", StringComparison.OrdinalIgnoreCase)) return resolution.ToLowerInvariant(); - // Parse "WIDTHxHEIGHT" format var parts = resolution.ToLowerInvariant().Split('x'); if (parts.Length == 2 && int.TryParse(parts[1], out var height)) { @@ -668,32 +524,11 @@ private static string NormalizeResolution(string resolution) /// public class VideoGenerationTaskResponse { - /// - /// Unique identifier for the video generation task. - /// public string TaskId { get; set; } = string.Empty; - - /// - /// Current status of the task (pending, processing, completed, failed). - /// public string Status { get; set; } = string.Empty; - - /// - /// When the task was created. - /// public DateTimeOffset CreatedAt { get; set; } - - /// - /// Estimated time when the video will be ready. - /// public DateTimeOffset? EstimatedCompletionTime { get; set; } - - /// - /// URL to check the status of this task. - /// public string CheckStatusUrl { get; set; } = string.Empty; - - // SignalRToken removed - clients will use ephemeral keys for SignalR authentication } /// @@ -701,49 +536,14 @@ public class VideoGenerationTaskResponse /// public class VideoGenerationTaskStatus { - /// - /// Unique identifier for the task. - /// public string TaskId { get; set; } = string.Empty; - - /// - /// Current status (pending, running, completed, failed, cancelled). - /// public string Status { get; set; } = string.Empty; - - /// - /// Progress percentage (0-100). - /// public int? Progress { get; set; } - - /// - /// When the task was created. - /// public DateTimeOffset CreatedAt { get; set; } - - /// - /// When the task was last updated. - /// public DateTimeOffset UpdatedAt { get; set; } - - /// - /// When the task completed (if applicable). - /// public DateTimeOffset? CompletedAt { get; set; } - - /// - /// Error message if the task failed. - /// public string? Error { get; set; } - - /// - /// Result data (internal use). - /// - public string? Result { get; set; } - - /// - /// The video generation response if completed. - /// - public VideoGenerationResponse? VideoResponse { get; set; } + public string? ResultRaw { get; set; } + public VideoGenerationResponse? Result { get; set; } } } diff --git a/Services/ConduitLLM.Gateway/EventHandlers/AsyncTaskCacheInvalidationHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/AsyncTaskCacheInvalidationHandler.cs index c7dbe7e5b..ee7680ad0 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/AsyncTaskCacheInvalidationHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/AsyncTaskCacheInvalidationHandler.cs @@ -35,11 +35,12 @@ public AsyncTaskCacheInvalidationHandler( public async Task Consume(ConsumeContext context) { var message = context.Message; - + // For created events, we don't need to invalidate cache // The task was just created in DB and will be cached on first access - _logger.LogDebug("Async task created event received for task {TaskId}", message.TaskId); - + _logger.LogDebug("Async task created event received for task {TaskId} (type: {TaskType}, no cache invalidation needed)", + message.TaskId, message.TaskType); + await Task.CompletedTask; } @@ -47,21 +48,25 @@ public async Task Consume(ConsumeContext context) public async Task Consume(ConsumeContext context) { var message = context.Message; - + + _logger.LogDebug("Processing AsyncTaskUpdated event for task {TaskId}, new state: {State}", + message.TaskId, message.State); + try { // Invalidate cache for updated task var cacheKey = GetTaskKey(message.TaskId); await _cache.RemoveAsync(cacheKey); - + _logger.LogInformation( - "Cache invalidated for async task {TaskId} after update to state {State}", - message.TaskId, + "Cache invalidated for async task {TaskId} after update to state {State}", + message.TaskId, message.State); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating cache for async task {TaskId}", message.TaskId); + _logger.LogError(ex, "Failed to invalidate cache for async task {TaskId} (state: {State})", + message.TaskId, message.State); // Don't throw - cache invalidation failures shouldn't break the system } } @@ -70,18 +75,20 @@ public async Task Consume(ConsumeContext context) public async Task Consume(ConsumeContext context) { var message = context.Message; - + + _logger.LogDebug("Processing AsyncTaskDeleted event for task {TaskId}", message.TaskId); + try { // Invalidate cache for deleted task var cacheKey = GetTaskKey(message.TaskId); await _cache.RemoveAsync(cacheKey); - + _logger.LogInformation("Cache invalidated for deleted async task {TaskId}", message.TaskId); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating cache for async task {TaskId}", message.TaskId); + _logger.LogError(ex, "Failed to invalidate cache for deleted async task {TaskId}", message.TaskId); // Don't throw - cache invalidation failures shouldn't break the system } } diff --git a/Services/ConduitLLM.Gateway/EventHandlers/BatchInvalidationEventHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/BatchInvalidationEventHandler.cs index 21755bba4..8c460ec0f 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/BatchInvalidationEventHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/BatchInvalidationEventHandler.cs @@ -34,7 +34,7 @@ public async Task Consume(ConsumeContext context) { var requests = ExtractInvalidationRequests(context.Message); - if (requests.Count() > 0) + if (requests.Any()) { // Group by cache type for efficient processing var groupedRequests = requests.GroupBy(r => GetCacheType(r)); diff --git a/Services/ConduitLLM.Gateway/EventHandlers/DiscoveryCacheInvalidationHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/DiscoveryCacheInvalidationHandler.cs index dbf8d0727..2b643d33e 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/DiscoveryCacheInvalidationHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/DiscoveryCacheInvalidationHandler.cs @@ -1,6 +1,7 @@ -using MassTransit; +using ConduitLLM.Core.Consumers; using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; +using Microsoft.Extensions.Logging; namespace ConduitLLM.Gateway.EventHandlers { @@ -8,47 +9,35 @@ namespace ConduitLLM.Gateway.EventHandlers /// Handles DiscoveryCacheInvalidationRequested events from Admin API /// Invalidates the discovery cache across all Gateway API instances /// - public class DiscoveryCacheInvalidationHandler : IConsumer + public class DiscoveryCacheInvalidationHandler : CacheInvalidationConsumerBase { private readonly IDiscoveryCacheService _discoveryCacheService; - private readonly ILogger _logger; public DiscoveryCacheInvalidationHandler( IDiscoveryCacheService discoveryCacheService, ILogger logger) + : base(logger) { _discoveryCacheService = discoveryCacheService ?? throw new ArgumentNullException(nameof(discoveryCacheService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - /// - /// Handles manual discovery cache invalidation requests from Admin API - /// - public async Task Consume(ConsumeContext context) - { - var @event = context.Message; + protected override Task InvalidateCacheAsync(DiscoveryCacheInvalidationRequested message) + => _discoveryCacheService.InvalidateAllDiscoveryAsync(); - try - { - _logger.LogInformation( - "Processing discovery cache invalidation request. Reason: {Reason}, Requested by: {RequestedBy}", - @event.Reason, - @event.RequestedBy); + protected override void LogReceived(DiscoveryCacheInvalidationRequested message) + => Logger.LogInformation( + "Processing discovery cache invalidation request. Reason: {Reason}, Requested by: {RequestedBy}", + message.Reason, + message.RequestedBy); - // Invalidate all discovery cache entries - await _discoveryCacheService.InvalidateAllDiscoveryAsync(); + protected override void LogSuccess(DiscoveryCacheInvalidationRequested message) + => Logger.LogInformation( + "Successfully invalidated all discovery cache entries. Reason: {Reason}", + message.Reason); - _logger.LogInformation( - "Successfully invalidated all discovery cache entries. Reason: {Reason}", - @event.Reason); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to invalidate discovery cache. Reason: {Reason}", - @event.Reason); - throw; // Re-throw to trigger MassTransit retry logic - } - } + protected override void LogFailure(DiscoveryCacheInvalidationRequested message, Exception ex) + => Logger.LogError(ex, + "Failed to invalidate discovery cache. Reason: {Reason}", + message.Reason); } } diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationCompletedHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationCompletedHandler.cs index 6c1228da9..d831cbc77 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationCompletedHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationCompletedHandler.cs @@ -1,4 +1,7 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Events; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; using MassTransit; using Microsoft.Extensions.Caching.Memory; @@ -10,17 +13,19 @@ namespace ConduitLLM.Gateway.EventHandlers /// public class ImageGenerationCompletedHandler : IConsumer { + private readonly IAsyncTaskService _asyncTaskService; private readonly IMemoryCache _progressCache; private readonly IImageGenerationNotificationService _notificationService; private readonly ILogger _logger; - private const string ProgressCacheKeyPrefix = "image_generation_progress_"; private const string CompletedTasksCacheKey = "completed_image_tasks"; public ImageGenerationCompletedHandler( + IAsyncTaskService asyncTaskService, IMemoryCache progressCache, IImageGenerationNotificationService notificationService, ILogger logger) { + _asyncTaskService = asyncTaskService; _progressCache = progressCache; _notificationService = notificationService; _logger = logger; @@ -35,10 +40,33 @@ public async Task Consume(ConsumeContext context) try { + // Update task status to completed (if async task record exists) + var taskStatus = await _asyncTaskService.GetTaskStatusAsync(message.TaskId, context.CancellationToken); + if (taskStatus != null) + { + var result = new + { + images = message.Images.Select(img => new { url = img.Url, revisedPrompt = img.RevisedPrompt }).ToList(), + imageCount = message.Images.Count(), + provider = message.Provider, + model = message.Model, + duration = message.Duration.TotalSeconds, + cost = message.Cost + }; + + await _asyncTaskService.UpdateTaskStatusAsync( + message.TaskId, + TaskState.Completed, + progress: 100, + result: result, + error: null, + cancellationToken: context.CancellationToken); + } + // Clear progress cache for this task - var progressCacheKey = $"{ProgressCacheKeyPrefix}{message.TaskId}"; + var progressCacheKey = CacheKeys.MediaProgress.ImageProgress(message.TaskId); _progressCache.Remove(progressCacheKey); - + // Store completion info for analytics and audit var completionData = new { @@ -54,57 +82,28 @@ public async Task Consume(ConsumeContext context) }; // Cache completion data for recent tasks (24 hours) - UpdateCompletedTasksCache(completionData); + MediaGenerationHandlerHelper.UpdateCompletedTasksCache(_progressCache, CompletedTasksCacheKey, completionData); // Log performance metrics var avgTimePerImage = message.Duration.TotalSeconds / Math.Max(1, message.Images.Count()); _logger.LogInformation("Image generation performance - Provider: {Provider}, Model: {Model}, Avg time per image: {AvgTime}s, Total cost: ${Cost}", - message.Provider, message.Model, avgTimePerImage, message.Cost); + LoggingSanitizer.S(message.Provider), LoggingSanitizer.S(message.Model), avgTimePerImage, message.Cost); // Track provider-specific metrics LogProviderMetrics(message.Provider, message.Model, message.Images.Count(), message.Duration, message.Cost); - // Future: Trigger post-processing workflows - // - Image optimization - // - Metadata extraction - // - CDN cache warming - // Send completion notification to WebAdmin await _notificationService.NotifyImageGenerationCompletedAsync( message.TaskId, message.Images.Select(img => img.Url ?? string.Empty).ToArray(), message.Duration, message.Cost); - - // Future: Send webhook notification if configured - // await _webhookService.SendImageGenerationCompletedWebhook(message); - } catch (Exception ex) { _logger.LogError(ex, "Error processing image generation completion for task {TaskId}", message.TaskId); throw; // Let MassTransit handle retry } - - await Task.CompletedTask; - } - - private void UpdateCompletedTasksCache(object completionData) - { - // Maintain a rolling list of recently completed tasks - var completedTasks = _progressCache.Get>(CompletedTasksCacheKey) ?? new List(); - - // Add new completion - completedTasks.Add(completionData); - - // Keep only last 100 completed tasks - if (completedTasks.Count() > 100) - { - completedTasks = completedTasks.Skip(completedTasks.Count() - 100).ToList(); - } - - // Cache for 24 hours - _progressCache.Set(CompletedTasksCacheKey, completedTasks, TimeSpan.FromHours(24)); } private void LogProviderMetrics(string provider, string model, int imageCount, TimeSpan duration, decimal cost) diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationFailedHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationFailedHandler.cs index aefcd4e1a..f2c97945d 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationFailedHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationFailedHandler.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; using MassTransit; @@ -7,29 +8,24 @@ namespace ConduitLLM.Gateway.EventHandlers { /// - /// Handles ImageGenerationFailed events to log failures, implement retry logic, and clean up resources. + /// Handles ImageGenerationFailed events to track failure metrics, analyze error patterns, and send notifications. /// public class ImageGenerationFailedHandler : IConsumer { + private readonly IAsyncTaskService _asyncTaskService; private readonly IMemoryCache _progressCache; - private readonly IMediaStorageService _storageService; - private readonly IPublishEndpoint _publishEndpoint; private readonly IImageGenerationNotificationService _notificationService; private readonly ILogger _logger; - private const string ProgressCacheKeyPrefix = "image_generation_progress_"; private const string FailureCountCacheKeyPrefix = "image_generation_failures_"; - private const int MaxRetryAttempts = 3; public ImageGenerationFailedHandler( + IAsyncTaskService asyncTaskService, IMemoryCache progressCache, - IMediaStorageService storageService, - IPublishEndpoint publishEndpoint, IImageGenerationNotificationService notificationService, ILogger logger) { + _asyncTaskService = asyncTaskService; _progressCache = progressCache; - _storageService = storageService; - _publishEndpoint = publishEndpoint; _notificationService = notificationService; _logger = logger; } @@ -37,160 +33,80 @@ public ImageGenerationFailedHandler( public async Task Consume(ConsumeContext context) { var message = context.Message; - - _logger.LogError("Image generation failed for task {TaskId}: {Error} (Provider: {Provider}, Retryable: {IsRetryable}, Attempt: {AttemptCount})", + + _logger.LogError("Image generation failed for task {TaskId}: {Error} (Provider: {Provider}, Retryable: {IsRetryable}, Attempt: {AttemptCount})", message.TaskId, message.Error, message.Provider, message.IsRetryable, message.AttemptCount); try { - // Clear progress cache for failed task - var progressCacheKey = $"{ProgressCacheKeyPrefix}{message.TaskId}"; - _progressCache.Remove(progressCacheKey); - - // Track failure metrics - await TrackFailureMetrics(message); - - // Implement retry logic for retryable errors - if (message.IsRetryable && message.AttemptCount < MaxRetryAttempts) + // Update task status to failed (if async task record exists) + var taskStatus = await _asyncTaskService.GetTaskStatusAsync(message.TaskId, context.CancellationToken); + if (taskStatus != null) { - _logger.LogInformation("Scheduling retry for task {TaskId} (attempt {NextAttempt} of {MaxAttempts})", - message.TaskId, message.AttemptCount + 1, MaxRetryAttempts); - - // Future: Re-queue the image generation request with increased attempt count - // await _publishEndpoint.Publish(new ImageGenerationRequested - // { - // TaskId = message.TaskId, - // VirtualKeyId = message.VirtualKeyId, - // // ... copy original request details ... - // AttemptCount = message.AttemptCount + 1 - // }, context => context.Delay = TimeSpan.FromSeconds(Math.Pow(2, message.AttemptCount))); - - // For now, just log the retry intention - _logger.LogWarning("Retry mechanism not yet implemented - task {TaskId} will not be retried automatically", - message.TaskId); - } - else - { - // Final failure - clean up any partial resources - await CleanupPartialResources(message); - - // Log final failure details - _logger.LogError("Image generation permanently failed for task {TaskId} after {AttemptCount} attempts. Error: {Error}", - message.TaskId, message.AttemptCount, message.Error); + var errorDetails = new + { + Error = message.Error, + ErrorCode = message.ErrorCode, + Provider = message.Provider, + IsRetryable = message.IsRetryable, + AttemptCount = message.AttemptCount + }; + + await _asyncTaskService.UpdateTaskStatusAsync( + message.TaskId, + TaskState.Failed, + progress: null, + errorDetails, + message.Error, + context.CancellationToken); } - - // Analyze error patterns for common issues - AnalyzeErrorPattern(message); - + + // Clear progress cache for failed task + var progressCacheKey = CacheKeys.MediaProgress.ImageProgress(message.TaskId); + _progressCache.Remove(progressCacheKey); + + // Track per-provider failure count + MediaGenerationHandlerHelper.TrackFailureMetrics( + _progressCache, FailureCountCacheKeyPrefix, message.Provider, "image", _logger); + + // Analyze error patterns for actionable diagnostics + MediaGenerationHandlerHelper.AnalyzeErrorPattern( + message.Error, message.TaskId, _logger, + ImageSpecificErrorPatterns); + // Send failure notification to WebAdmin await _notificationService.NotifyImageGenerationFailedAsync( message.TaskId, message.Error, message.IsRetryable); - - // Future: Send alert for critical failures - if (IsCriticalFailure(message)) + + // Log permanent failure + if (!message.IsRetryable) + { + _logger.LogError("Image generation permanently failed for task {TaskId} after {AttemptCount} attempts. Error: {Error}", + message.TaskId, message.AttemptCount, message.Error); + } + + // Flag critical failures (auth, account, credits) for immediate attention + if (MediaGenerationHandlerHelper.IsCriticalFailure(message.Error)) { _logger.LogCritical("Critical image generation failure detected for provider {Provider}: {Error}", message.Provider, message.Error); - // await _alertService.SendCriticalFailureAlert(message); } - } catch (Exception ex) { _logger.LogError(ex, "Error processing image generation failure for task {TaskId}", message.TaskId); throw; // Let MassTransit handle retry } - - await Task.CompletedTask; } - private async Task TrackFailureMetrics(ImageGenerationFailed message) + /// + /// Image-specific error patterns beyond the common set. + /// + private static readonly Dictionary ImageSpecificErrorPatterns = new() { - // Track failure count by provider - var failureCacheKey = $"{FailureCountCacheKeyPrefix}{message.Provider}"; - var failureCount = 0; - - if (_progressCache.TryGetValue(failureCacheKey, out var existingCount)) - { - failureCount = existingCount; - } - - failureCount++; - - // Cache failure count for 1 hour sliding window - _progressCache.Set(failureCacheKey, failureCount, TimeSpan.FromHours(1)); - - // Log metrics - _logger.LogWarning("Provider {Provider} failure count in last hour: {FailureCount}", - message.Provider, failureCount); - - await Task.CompletedTask; - } - - private async Task CleanupPartialResources(ImageGenerationFailed message) - { - try - { - // Clean up any partial uploads or temporary files - _logger.LogInformation("Cleaning up partial resources for failed task {TaskId}", message.TaskId); - - // Future: Implement actual cleanup logic - // - Check for partial uploads in storage - // - Remove temporary files - // - Clean up any reserved resources - - await Task.CompletedTask; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error cleaning up resources for failed task {TaskId}", message.TaskId); - // Don't throw - cleanup failures shouldn't prevent event processing - } - } - - private void AnalyzeErrorPattern(ImageGenerationFailed message) - { - // Analyze common error patterns - var errorPatterns = new Dictionary - { - ["rate limit"] = "Provider rate limit exceeded - consider implementing backoff", - ["timeout"] = "Request timeout - provider may be experiencing high load", - ["invalid api key"] = "Authentication failure - check provider credentials", - ["insufficient credits"] = "Provider account has insufficient credits", - ["content policy"] = "Content violates provider's usage policy", - ["model not found"] = "Requested model is not available", - ["invalid size"] = "Requested image size is not supported" - }; - - var lowerError = message.Error.ToLowerInvariant(); - foreach (var (pattern, analysis) in errorPatterns) - { - if (lowerError.Contains(pattern)) - { - _logger.LogWarning("Error pattern detected for task {TaskId}: {Analysis}", - message.TaskId, analysis); - break; - } - } - } - - private bool IsCriticalFailure(ImageGenerationFailed message) - { - // Determine if this is a critical failure requiring immediate attention - var criticalErrorPatterns = new[] - { - "invalid api key", - "authentication failed", - "unauthorized", - "forbidden", - "account suspended", - "insufficient credits" - }; - - var lowerError = message.Error.ToLowerInvariant(); - return criticalErrorPatterns.Any(pattern => lowerError.Contains(pattern)); - } + ["invalid size"] = "Requested image size is not supported" + }; } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationProgressHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationProgressHandler.cs index a17473233..09999a47f 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationProgressHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/ImageGenerationProgressHandler.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; using MassTransit; @@ -15,7 +16,6 @@ public class ImageGenerationProgressHandler : IConsumer private readonly IAsyncTaskService _taskService; private readonly IImageGenerationNotificationService _notificationService; private readonly ILogger _logger; - private const string ProgressCacheKeyPrefix = "image_generation_progress_"; public ImageGenerationProgressHandler( IMemoryCache progressCache, @@ -39,7 +39,7 @@ public async Task Consume(ConsumeContext context) try { // Update progress cache for real-time queries - var cacheKey = $"{ProgressCacheKeyPrefix}{message.TaskId}"; + var cacheKey = CacheKeys.MediaProgress.ImageProgress(message.TaskId); var progressData = new { TaskId = message.TaskId, @@ -89,8 +89,6 @@ await _notificationService.NotifyImageGenerationProgressAsync( _logger.LogError(ex, "Error processing image generation progress for task {TaskId}", message.TaskId); throw; // Let MassTransit handle retry } - - await Task.CompletedTask; } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/EventHandlers/MediaGenerationHandlerHelper.cs b/Services/ConduitLLM.Gateway/EventHandlers/MediaGenerationHandlerHelper.cs new file mode 100644 index 000000000..5d9f79e51 --- /dev/null +++ b/Services/ConduitLLM.Gateway/EventHandlers/MediaGenerationHandlerHelper.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace ConduitLLM.Gateway.EventHandlers +{ + /// + /// Shared utility methods for image and video generation event handlers. + /// Consolidates duplicated cache management, failure tracking, and error analysis logic. + /// + internal static class MediaGenerationHandlerHelper + { + #region Completed Handler Helpers + + /// + /// Maintains a rolling list of recently completed media generation tasks in memory cache. + /// Keeps the last 100 entries with a 24-hour TTL. + /// + public static void UpdateCompletedTasksCache(IMemoryCache cache, string cacheKey, object completionData) + { + var completedTasks = cache.Get>(cacheKey) ?? new List(); + + completedTasks.Add(completionData); + + if (completedTasks.Count > 100) + { + completedTasks = completedTasks.Skip(completedTasks.Count - 100).ToList(); + } + + cache.Set(cacheKey, completedTasks, TimeSpan.FromHours(24)); + } + + #endregion + + #region Failed Handler Helpers + + /// + /// Tracks per-provider failure count in a 1-hour sliding window. + /// Returns the updated failure count. + /// + public static int TrackFailureMetrics( + IMemoryCache cache, string cacheKeyPrefix, string provider, string mediaType, ILogger logger) + { + var failureCacheKey = $"{cacheKeyPrefix}{provider}"; + var failureCount = 0; + + if (cache.TryGetValue(failureCacheKey, out var existingCount)) + { + failureCount = existingCount; + } + + failureCount++; + cache.Set(failureCacheKey, failureCount, TimeSpan.FromHours(1)); + + logger.LogWarning("Provider {Provider} {MediaType} failure count in last hour: {FailureCount}", + provider, mediaType, failureCount); + + return failureCount; + } + + /// + /// Common error patterns shared across all media generation types. + /// + private static readonly Dictionary CommonErrorPatterns = new() + { + ["rate limit"] = "Provider rate limit exceeded - consider implementing backoff", + ["timeout"] = "Request timeout - provider may be experiencing high load", + ["invalid api key"] = "Authentication failure - check provider credentials", + ["insufficient credits"] = "Provider account has insufficient credits", + ["content policy"] = "Content violates provider's usage policy", + ["model not found"] = "Requested model is not available" + }; + + /// + /// Analyzes the error message against known patterns and logs actionable diagnostics. + /// Checks common patterns first, then any additional media-specific patterns. + /// + public static void AnalyzeErrorPattern( + string error, string taskId, ILogger logger, + Dictionary? additionalPatterns = null) + { + var lowerError = error.ToLowerInvariant(); + + // Check common patterns first, then additional ones + var allPatterns = additionalPatterns != null + ? CommonErrorPatterns.Concat(additionalPatterns) + : CommonErrorPatterns; + + foreach (var (pattern, analysis) in allPatterns) + { + if (lowerError.Contains(pattern)) + { + logger.LogWarning("Error pattern detected for task {TaskId}: {Analysis}", + taskId, analysis); + return; + } + } + } + + /// + /// Critical error patterns that indicate provider-level issues requiring immediate attention + /// (authentication failures, account problems, insufficient funds). + /// + private static readonly string[] CriticalErrorPatterns = + { + "invalid api key", + "authentication failed", + "unauthorized", + "forbidden", + "account suspended", + "insufficient credits" + }; + + /// + /// Checks if the error represents a critical failure that needs immediate attention. + /// + public static bool IsCriticalFailure(string error) + { + var lowerError = error.ToLowerInvariant(); + return CriticalErrorPatterns.Any(pattern => lowerError.Contains(pattern)); + } + + /// + /// Categorizes an error into a type string for structured metrics/alerting. + /// + public static string DetermineErrorType(string error, string? errorCode) + { + if (string.IsNullOrEmpty(error)) + return "unknown"; + + var lowerError = error.ToLowerInvariant(); + + if (lowerError.Contains("rate limit") || lowerError.Contains("quota")) + return "rate_limit"; + if (lowerError.Contains("auth") || lowerError.Contains("unauthorized")) + return "authentication"; + if (lowerError.Contains("timeout")) + return "timeout"; + if (lowerError.Contains("invalid") || lowerError.Contains("bad request")) + return "validation"; + if (lowerError.Contains("not found")) + return "not_found"; + if (lowerError.Contains("server error") || lowerError.Contains("internal")) + return "server_error"; + + return "other"; + } + + #endregion + } +} diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ModelCacheInvalidationHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/ModelCacheInvalidationHandler.cs index 7f304f665..d9c2c0b6d 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/ModelCacheInvalidationHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/ModelCacheInvalidationHandler.cs @@ -1,6 +1,7 @@ -using MassTransit; +using ConduitLLM.Core.Consumers; using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; +using Microsoft.Extensions.Logging; namespace ConduitLLM.Gateway.EventHandlers { @@ -8,62 +9,52 @@ namespace ConduitLLM.Gateway.EventHandlers /// Handles ModelUpdated events to invalidate discovery cache /// Critical for ensuring updated model parameters are reflected in the discovery API /// - public class ModelCacheInvalidationHandler : IConsumer + public class ModelCacheInvalidationHandler : CacheInvalidationConsumerBase { private readonly IDiscoveryCacheService _discoveryCacheService; - private readonly ILogger _logger; public ModelCacheInvalidationHandler( IDiscoveryCacheService discoveryCacheService, ILogger logger) + : base(logger) { _discoveryCacheService = discoveryCacheService ?? throw new ArgumentNullException(nameof(discoveryCacheService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - /// - /// Handles ModelUpdated events by invalidating discovery cache - /// - public async Task Consume(ConsumeContext context) + protected override Task InvalidateCacheAsync(ModelUpdated message) + => _discoveryCacheService.InvalidateAllDiscoveryAsync(); + + protected override void LogReceived(ModelUpdated message) { - var @event = context.Message; - - try - { - _logger.LogInformation( - "Processing ModelUpdated event: {ModelName} (ID: {ModelId}, ChangeType: {ChangeType}, ParametersChanged: {ParametersChanged})", - @event.ModelName, - @event.ModelId, - @event.ChangeType, - @event.ParametersChanged); + Logger.LogInformation( + "Processing ModelUpdated event: {ModelName} (ID: {ModelId}, ChangeType: {ChangeType}, ParametersChanged: {ParametersChanged})", + message.ModelName, + message.ModelId, + message.ChangeType, + message.ParametersChanged); + } - // Invalidate all discovery cache entries - // This ensures that any capability-filtered queries get fresh data - await _discoveryCacheService.InvalidateAllDiscoveryAsync(); - - _logger.LogInformation( - "Invalidated all discovery cache entries after {ChangeType} of model {ModelName} (ID: {ModelId})", - @event.ChangeType, - @event.ModelName, - @event.ModelId); - - // Log specific parameter changes for debugging - if (@event.ParametersChanged) - { - _logger.LogInformation( - "Model parameters were updated for {ModelName} - UI components will reflect new parameter definitions", - @event.ModelName); - } - } - catch (Exception ex) + protected override void LogSuccess(ModelUpdated message) + { + Logger.LogInformation( + "Invalidated all discovery cache entries after {ChangeType} of model {ModelName} (ID: {ModelId})", + message.ChangeType, + message.ModelName, + message.ModelId); + + if (message.ParametersChanged) { - _logger.LogError(ex, - "Failed to invalidate discovery cache after {ChangeType} of model {ModelName} (ID: {ModelId})", - @event.ChangeType, - @event.ModelName, - @event.ModelId); - throw; // Re-throw to trigger MassTransit retry logic + Logger.LogInformation( + "Model parameters were updated for {ModelName} - UI components will reflect new parameter definitions", + message.ModelName); } } + + protected override void LogFailure(ModelUpdated message, Exception ex) + => Logger.LogError(ex, + "Failed to invalidate discovery cache after {ChangeType} of model {ModelName} (ID: {ModelId})", + message.ChangeType, + message.ModelName, + message.ModelId); } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ProviderCacheInvalidationHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/ProviderCacheInvalidationHandler.cs new file mode 100644 index 000000000..16140de82 --- /dev/null +++ b/Services/ConduitLLM.Gateway/EventHandlers/ProviderCacheInvalidationHandler.cs @@ -0,0 +1,82 @@ +using MassTransit; +using ConduitLLM.Core.Events; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Gateway.Interfaces; + +namespace ConduitLLM.Gateway.EventHandlers +{ + /// + /// Handles Provider events to refresh in-memory settings and invalidate discovery cache. + /// Critical for maintaining runtime configuration consistency. + /// + public class ProviderCacheInvalidationHandler : + IConsumer, + IConsumer, + IConsumer + { + private readonly ISettingsRefreshService _settingsRefreshService; + private readonly IDiscoveryCacheService _discoveryCacheService; + private readonly ILogger _logger; + + public ProviderCacheInvalidationHandler( + ISettingsRefreshService settingsRefreshService, + IDiscoveryCacheService discoveryCacheService, + ILogger logger) + { + _settingsRefreshService = settingsRefreshService ?? throw new ArgumentNullException(nameof(settingsRefreshService)); + _discoveryCacheService = discoveryCacheService ?? throw new ArgumentNullException(nameof(discoveryCacheService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Consume(ConsumeContext context) + { + var @event = context.Message; + await RefreshAndInvalidateAsync(@event.ProviderId, "creation", + invalidateDiscovery: true); + } + + public async Task Consume(ConsumeContext context) + { + var @event = context.Message; + var invalidateDiscovery = @event.ChangedProperties.Contains("IsEnabled") || + @event.ChangedProperties.Contains("IsActive"); + await RefreshAndInvalidateAsync(@event.ProviderId, "update", + invalidateDiscovery: invalidateDiscovery); + } + + public async Task Consume(ConsumeContext context) + { + var @event = context.Message; + await RefreshAndInvalidateAsync(@event.ProviderId, "deletion", + invalidateDiscovery: true); + } + + private async Task RefreshAndInvalidateAsync(int providerId, string operation, bool invalidateDiscovery) + { + try + { + _logger.LogInformation( + "Processing provider {Operation} event: Provider ID {ProviderId}", + operation, providerId); + + await _settingsRefreshService.RefreshProvidersAsync(); + + if (invalidateDiscovery) + { + await _discoveryCacheService.InvalidateAllDiscoveryAsync(); + } + + _logger.LogInformation( + "Successfully refreshed provider credentials after {Operation} of Provider ID {ProviderId}", + operation, providerId); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to refresh provider credentials after {Operation} of Provider ID {ProviderId}", + operation, providerId); + throw; + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ProviderCredentialCacheInvalidationHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/ProviderCredentialCacheInvalidationHandler.cs deleted file mode 100644 index 59d27025b..000000000 --- a/Services/ConduitLLM.Gateway/EventHandlers/ProviderCredentialCacheInvalidationHandler.cs +++ /dev/null @@ -1,135 +0,0 @@ -using MassTransit; -using ConduitLLM.Core.Events; -using ConduitLLM.Core.Interfaces; -using ConduitLLM.Gateway.Interfaces; - -namespace ConduitLLM.Gateway.EventHandlers -{ - /// - /// Handles Provider events to refresh in-memory settings and invalidate discovery cache - /// Critical for maintaining runtime configuration consistency - /// - public class ProviderCacheInvalidationHandler : - IConsumer, - IConsumer, - IConsumer - { - private readonly ISettingsRefreshService _settingsRefreshService; - private readonly IDiscoveryCacheService _discoveryCacheService; - private readonly ILogger _logger; - - public ProviderCacheInvalidationHandler( - ISettingsRefreshService settingsRefreshService, - IDiscoveryCacheService discoveryCacheService, - ILogger logger) - { - _settingsRefreshService = settingsRefreshService ?? throw new ArgumentNullException(nameof(settingsRefreshService)); - _discoveryCacheService = discoveryCacheService ?? throw new ArgumentNullException(nameof(discoveryCacheService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Handles ProviderCreated events by refreshing provider credentials and invalidating discovery cache - /// - public async Task Consume(ConsumeContext context) - { - var @event = context.Message; - - try - { - _logger.LogInformation( - "Processing ProviderCreated event: Provider ID {ProviderId} ({ProviderName})", - @event.ProviderId, - @event.ProviderName); - - // Refresh all provider credentials to ensure consistency - await _settingsRefreshService.RefreshProvidersAsync(); - - // Invalidate discovery cache as new provider affects available models - await _discoveryCacheService.InvalidateAllDiscoveryAsync(); - - _logger.LogInformation( - "Successfully refreshed provider credentials and invalidated discovery cache after creation of Provider ID {ProviderId}", - @event.ProviderId); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to refresh provider credentials after creation of Provider ID {ProviderId}", - @event.ProviderId); - throw; // Re-throw to trigger MassTransit retry logic - } - } - - /// - /// Handles ProviderUpdated events by refreshing provider credentials and invalidating discovery cache - /// - public async Task Consume(ConsumeContext context) - { - var @event = context.Message; - - try - { - _logger.LogInformation( - "Processing ProviderUpdated event: Provider ID {ProviderId}", - @event.ProviderId); - - // Refresh all provider credentials to ensure consistency - await _settingsRefreshService.RefreshProvidersAsync(); - - // Invalidate discovery cache if provider enabled status changed - if (@event.ChangedProperties.Contains("IsEnabled") || - @event.ChangedProperties.Contains("IsActive")) - { - await _discoveryCacheService.InvalidateAllDiscoveryAsync(); - _logger.LogInformation( - "Invalidated discovery cache after status change of Provider ID {ProviderId}", - @event.ProviderId); - } - - _logger.LogInformation( - "Successfully refreshed provider credentials after update of Provider ID {ProviderId}", - @event.ProviderId); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to refresh provider credentials after update of Provider ID {ProviderId}", - @event.ProviderId); - throw; // Re-throw to trigger MassTransit retry logic - } - } - - /// - /// Handles ProviderDeleted events by refreshing provider credentials from the database - /// - public async Task Consume(ConsumeContext context) - { - var @event = context.Message; - - try - { - _logger.LogInformation( - "Processing ProviderDeleted event: Provider ID {ProviderId}", - @event.ProviderId); - - // Refresh all provider credentials to ensure consistency - await _settingsRefreshService.RefreshProvidersAsync(); - - // Invalidate discovery cache as provider deletion affects available models - await _discoveryCacheService.InvalidateAllDiscoveryAsync(); - - _logger.LogInformation( - "Successfully refreshed provider credentials and invalidated discovery cache after deletion of Provider ID {ProviderId}", - @event.ProviderId); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Failed to refresh provider credentials after deletion of Provider ID {ProviderId}", - @event.ProviderId); - throw; // Re-throw to trigger MassTransit retry logic - } - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ResilientEventHandlerBase.cs b/Services/ConduitLLM.Gateway/EventHandlers/ResilientEventHandlerBase.cs deleted file mode 100644 index 36808e587..000000000 --- a/Services/ConduitLLM.Gateway/EventHandlers/ResilientEventHandlerBase.cs +++ /dev/null @@ -1,228 +0,0 @@ -using System.Diagnostics; - -using MassTransit; - -using Polly; -using Polly.CircuitBreaker; - -namespace ConduitLLM.Gateway.EventHandlers -{ - /// - /// Base class for resilient event handlers with built-in error handling, - /// circuit breakers, and fallback mechanisms. - /// - /// The type of event this handler consumes - public abstract class ResilientEventHandlerBase : IConsumer - where TEvent : class - { - protected readonly ILogger Logger; - private readonly IAsyncPolicy _resiliencePolicy; - private readonly string _handlerName; - - /// - /// Gets the circuit breaker state for monitoring - /// - protected CircuitState CircuitState => _circuitBreaker?.CircuitState ?? CircuitState.Closed; - - private readonly AsyncCircuitBreakerPolicy? _circuitBreaker; - - protected ResilientEventHandlerBase(ILogger logger) - { - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _handlerName = GetType().Name; - - // Build resilience policy with circuit breaker - var circuitBreakerPolicy = Policy - .Handle(ex => !IsTransientException(ex)) - .AdvancedCircuitBreakerAsync( - failureThreshold: 0.5, // 50% failure rate - samplingDuration: TimeSpan.FromMinutes(1), - minimumThroughput: GetCircuitBreakerThreshold(), - durationOfBreak: GetCircuitBreakerDuration(), - onBreak: (exception, duration) => OnCircuitBreakerOpen(exception, duration), - onReset: OnCircuitBreakerReset, - onHalfOpen: OnCircuitBreakerHalfOpen); - - // Store circuit breaker reference for state monitoring - _circuitBreaker = circuitBreakerPolicy; - - // Combine with retry policy for transient errors - var retryPolicy = Policy - .Handle(IsTransientException) - .WaitAndRetryAsync( - retryCount: GetRetryCount(), - sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), - onRetry: (outcome, timespan, retryCount, context) => - { - Logger.LogWarning( - "Retry {RetryCount} after {Delay}ms for {Handler}", - retryCount, timespan.TotalMilliseconds, _handlerName); - }); - - // Wrap with timeout policy - var timeoutPolicy = Policy.TimeoutAsync(GetTimeout()); - - // Combine all policies - _resiliencePolicy = Policy.WrapAsync(circuitBreakerPolicy, retryPolicy, timeoutPolicy); - } - - /// - /// Main consume method with resilience wrapper - /// - public async Task Consume(ConsumeContext context) - { - var stopwatch = Stopwatch.StartNew(); - var eventType = nameof(TEvent); - - try - { - Logger.LogDebug( - "Processing {EventType} in {Handler}", - eventType, _handlerName); - - // Execute with resilience policy - await _resiliencePolicy.ExecuteAsync(async () => - { - await HandleEventAsync(context.Message, context.CancellationToken); - }); - - stopwatch.Stop(); - Logger.LogDebug( - "Successfully processed {EventType} in {Handler} after {ElapsedMs}ms", - eventType, _handlerName, stopwatch.ElapsedMilliseconds); - } - catch (BrokenCircuitException ex) - { - stopwatch.Stop(); - Logger.LogWarning(ex, - "Circuit breaker is open for {Handler}. Falling back for {EventType}", - _handlerName, eventType); - - // Execute fallback when circuit is open - try - { - await HandleEventFallbackAsync(context.Message, context.CancellationToken); - } - catch (Exception fallbackEx) - { - Logger.LogError(fallbackEx, - "Fallback failed for {EventType} in {Handler}", - eventType, _handlerName); - throw; - } - } - catch (TimeoutException ex) - { - stopwatch.Stop(); - Logger.LogError(ex, - "Timeout processing {EventType} in {Handler} after {ElapsedMs}ms", - eventType, _handlerName, stopwatch.ElapsedMilliseconds); - - // Let MassTransit retry infrastructure handle it - throw; - } - catch (Exception ex) - { - stopwatch.Stop(); - Logger.LogError(ex, - "Failed to process {EventType} in {Handler} after {ElapsedMs}ms", - eventType, _handlerName, stopwatch.ElapsedMilliseconds); - - // For non-transient errors, try fallback before failing - if (!IsTransientException(ex)) - { - try - { - await HandleEventFallbackAsync(context.Message, context.CancellationToken); - return; // Fallback succeeded - } - catch (Exception fallbackEx) - { - Logger.LogError(fallbackEx, - "Fallback also failed for {EventType} in {Handler}", - eventType, _handlerName); - } - } - - throw; - } - } - - /// - /// Implement the main event handling logic - /// - protected abstract Task HandleEventAsync(TEvent message, CancellationToken cancellationToken); - - /// - /// Implement fallback logic when main handler fails or circuit is open - /// - protected virtual Task HandleEventFallbackAsync(TEvent message, CancellationToken cancellationToken) - { - // Default: log and skip - Logger.LogWarning( - "No fallback implemented for {Handler}. Event will be skipped.", - _handlerName); - return Task.CompletedTask; - } - - /// - /// Determine if an exception is transient and should be retried - /// - protected virtual bool IsTransientException(Exception ex) - { - return ex is TimeoutException || - ex is TaskCanceledException || - (ex.InnerException != null && IsTransientException(ex.InnerException)); - } - - /// - /// Get the number of retries for transient errors - /// - protected virtual int GetRetryCount() => 3; - - /// - /// Get the timeout for operations - /// - protected virtual TimeSpan GetTimeout() => TimeSpan.FromSeconds(30); - - /// - /// Get the circuit breaker failure threshold - /// - protected virtual int GetCircuitBreakerThreshold() => 5; - - /// - /// Get the circuit breaker open duration - /// - protected virtual TimeSpan GetCircuitBreakerDuration() => TimeSpan.FromMinutes(1); - - /// - /// Called when circuit breaker opens - /// - protected virtual void OnCircuitBreakerOpen(Exception? exception, TimeSpan duration) - { - Logger.LogWarning( - "Circuit breaker opened for {Handler} for {Duration}s due to: {Reason}", - _handlerName, duration.TotalSeconds, exception?.Message ?? "Unknown"); - } - - /// - /// Called when circuit breaker resets - /// - protected virtual void OnCircuitBreakerReset() - { - Logger.LogInformation( - "Circuit breaker reset for {Handler}", - _handlerName); - } - - /// - /// Called when circuit breaker is half-open - /// - protected virtual void OnCircuitBreakerHalfOpen() - { - Logger.LogInformation( - "Circuit breaker half-open for {Handler}, testing with next request", - _handlerName); - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/EventHandlers/ResilientSpendUpdateProcessor.cs b/Services/ConduitLLM.Gateway/EventHandlers/ResilientSpendUpdateProcessor.cs deleted file mode 100644 index c7920fed8..000000000 --- a/Services/ConduitLLM.Gateway/EventHandlers/ResilientSpendUpdateProcessor.cs +++ /dev/null @@ -1,191 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using ConduitLLM.Core.Events; -using ConduitLLM.Core.Interfaces; -using ConduitLLM.Configuration.Enums; -using ConduitLLM.Configuration.Interfaces; - -namespace ConduitLLM.Gateway.EventHandlers -{ - /// - /// Resilient implementation of spend update processing with fallback mechanisms - /// - public class ResilientSpendUpdateProcessor : ResilientEventHandlerBase - { - private readonly IVirtualKeyRepository _virtualKeyRepository; - private readonly IVirtualKeyGroupRepository _groupRepository; - private readonly IVirtualKeySpendHistoryRepository _spendHistoryRepository; - private readonly IDistributedCache _cache; - private readonly IDistributedLockService _lockService; - - public ResilientSpendUpdateProcessor( - IVirtualKeyRepository virtualKeyRepository, - IVirtualKeyGroupRepository groupRepository, - IVirtualKeySpendHistoryRepository spendHistoryRepository, - IDistributedCache cache, - IDistributedLockService lockService, - ILogger logger) - : base(logger) - { - _virtualKeyRepository = virtualKeyRepository ?? throw new ArgumentNullException(nameof(virtualKeyRepository)); - _groupRepository = groupRepository ?? throw new ArgumentNullException(nameof(groupRepository)); - _spendHistoryRepository = spendHistoryRepository ?? throw new ArgumentNullException(nameof(spendHistoryRepository)); - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _lockService = lockService ?? throw new ArgumentNullException(nameof(lockService)); - } - - protected override async Task HandleEventAsync(SpendUpdateRequested message, CancellationToken cancellationToken) - { - // Get the virtual key first to find its group - var virtualKey = await _virtualKeyRepository.GetByIdAsync(message.KeyId); - if (virtualKey == null) - { - Logger.LogError("Virtual key {KeyId} not found in database", message.KeyId); - throw new InvalidOperationException($"Virtual key {message.KeyId} not found"); - } - - // Get the key's group - var group = await _groupRepository.GetByIdAsync(virtualKey.VirtualKeyGroupId); - if (group == null) - { - Logger.LogError("Virtual key {KeyId} has invalid group ID {GroupId}", message.KeyId, virtualKey.VirtualKeyGroupId); - throw new InvalidOperationException($"Virtual key {message.KeyId} has invalid group"); - } - - // Use group-based locking to handle concurrent updates properly - var lockKey = $"group_spend_update_{group.Id}"; - - // Acquire distributed lock to prevent concurrent updates - using var lockHandle = await _lockService.AcquireLockAsync( - lockKey, - TimeSpan.FromSeconds(30), - cancellationToken); - - if (lockHandle == null) - { - throw new InvalidOperationException( - $"Failed to acquire lock for virtual key group {group.Id}. Another update may be in progress."); - } - - // Check if group has sufficient balance - if (group.Balance < message.Amount) - { - Logger.LogWarning( - "Virtual key group {GroupId} has insufficient balance. Current: ${Current:F2}, Requested: ${Amount:F2}", - group.Id, group.Balance, message.Amount); - - // This is a business rule violation, not a technical error - return; - } - - // Update the group balance and lifetime spent - var newBalance = await _groupRepository.AdjustBalanceAsync( - group.Id, - -message.Amount, - $"API usage by virtual key #{message.KeyId}", - "System", - ReferenceType.VirtualKey, - message.KeyId.ToString()); - - // Update virtual key's updated timestamp - virtualKey.UpdatedAt = DateTime.UtcNow; - await _virtualKeyRepository.UpdateAsync(virtualKey); - - // Record in spend history - var spendHistory = new Configuration.Entities.VirtualKeySpendHistory - { - VirtualKeyId = message.KeyId, - Amount = message.Amount, - Date = DateTime.UtcNow.Date, - Timestamp = DateTime.UtcNow - }; - - // Use the repository's Create method instead of AddAsync - await _spendHistoryRepository.CreateAsync(spendHistory); - - // Invalidate cache for both key and group - var keyCacheKey = $"vkey_spend:{message.KeyId}"; - var groupCacheKey = $"vkey_group:{group.Id}"; - await _cache.RemoveAsync(keyCacheKey, cancellationToken); - await _cache.RemoveAsync(groupCacheKey, cancellationToken); - - Logger.LogInformation( - "Successfully updated spend for virtual key {KeyId} in group {GroupId}: -${Amount:F2}, New Balance: ${Balance:F2}", - message.KeyId, group.Id, message.Amount, newBalance); - } - - protected override async Task HandleEventFallbackAsync(SpendUpdateRequested message, CancellationToken cancellationToken) - { - Logger.LogWarning( - "Executing fallback for spend update. Virtual Key: {KeyId}, Amount: ${Amount:F2}", - message.KeyId, message.Amount); - - try - { - // Fallback 1: Try to store in cache for later processing - var pendingKey = $"pending_spend:{message.KeyId}:{Guid.NewGuid()}"; - var serialized = System.Text.Json.JsonSerializer.Serialize(message); - - await _cache.SetStringAsync( - pendingKey, - serialized, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) - }, - cancellationToken); - - Logger.LogInformation( - "Spend update cached for later processing. Key: {PendingKey}", - pendingKey); - - // TODO: A background service should process these pending updates - } - catch (Exception ex) - { - Logger.LogError(ex, - "Failed to cache spend update for fallback processing"); - - // Fallback 2: Log to a file or external system - // This ensures we never lose spend data - await LogSpendUpdateToFileAsync(message); - } - } - - private async Task LogSpendUpdateToFileAsync(SpendUpdateRequested message) - { - var logEntry = $"{DateTime.UtcNow:O}|{message.KeyId}|{message.Amount}|N/A|N/A|{message.RequestId}"; - var logFile = $"spend_updates_fallback_{DateTime.UtcNow:yyyyMMdd}.log"; - - try - { - await System.IO.File.AppendAllTextAsync(logFile, logEntry + Environment.NewLine); - Logger.LogWarning( - "Spend update logged to fallback file: {LogFile}", - logFile); - } - catch (Exception ex) - { - Logger.LogCritical(ex, - "Failed to log spend update to fallback file. Data may be lost: {Message}", - logEntry); - } - } - - protected override bool IsTransientException(Exception ex) - { - // Add database-specific transient error detection - if (ex.Message.Contains("deadlock", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return base.IsTransientException(ex); - } - - protected override int GetCircuitBreakerThreshold() => 10; // Higher threshold for spend updates - protected override TimeSpan GetCircuitBreakerDuration() => TimeSpan.FromMinutes(5); - protected override TimeSpan GetTimeout() => TimeSpan.FromSeconds(15); // Faster timeout for spend updates - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/EventHandlers/SpendUpdatedHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/SpendUpdatedHandler.cs index 1bdf4373d..9f8a963c3 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/SpendUpdatedHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/SpendUpdatedHandler.cs @@ -67,6 +67,24 @@ public async Task Consume(ConsumeContext context) provider = providerHeader?.ToString() ?? "unknown"; } + // Log budget proximity warnings + if (maxBudget.HasValue && maxBudget.Value > 0) + { + var usagePercent = (message.NewTotalSpend / maxBudget.Value) * 100; + if (usagePercent >= 100) + { + _logger.LogWarning( + "Virtual Key {KeyId} has exceeded its budget: ${NewTotal:F2} / ${MaxBudget:F2} ({UsagePercent:F0}%)", + message.KeyId, message.NewTotalSpend, maxBudget.Value, usagePercent); + } + else if (usagePercent >= 90) + { + _logger.LogWarning( + "Virtual Key {KeyId} approaching budget limit: ${NewTotal:F2} / ${MaxBudget:F2} ({UsagePercent:F0}%)", + message.KeyId, message.NewTotalSpend, maxBudget.Value, usagePercent); + } + } + // Send the spend notification await _notificationService.NotifySpendUpdateAsync( message.KeyId, diff --git a/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationCompletedHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationCompletedHandler.cs index f2998b708..74f715c00 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationCompletedHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationCompletedHandler.cs @@ -1,11 +1,13 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Events; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; -using ConduitLLM.Gateway.Hubs; +using ConduitLLM.Core.Models; +using ConduitLLM.Gateway.Interfaces; using MassTransit; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; namespace ConduitLLM.Gateway.EventHandlers @@ -19,46 +21,70 @@ public class VideoGenerationCompletedHandler : IConsumer _hubContext; + private readonly IVideoGenerationNotificationService _notificationService; private readonly ILogger _logger; - private const string ProgressCacheKeyPrefix = "video_generation_progress_"; private const string CompletedTasksCacheKey = "completed_video_tasks"; public VideoGenerationCompletedHandler( IAsyncTaskService asyncTaskService, IRequestLogRepository requestLogRepository, IMemoryCache progressCache, - IHubContext hubContext, + IVideoGenerationNotificationService notificationService, ILogger logger) { _asyncTaskService = asyncTaskService; _requestLogRepository = requestLogRepository; _progressCache = progressCache; - _hubContext = hubContext; + _notificationService = notificationService; _logger = logger; } public async Task Consume(ConsumeContext context) { var message = context.Message; - - _logger.LogInformation("Processing video generation completion for request {RequestId}: Video generated in {Duration}s (cost: ${Cost})", + + _logger.LogInformation("Processing video generation completion for request {RequestId}: Video generated in {Duration}s (cost: ${Cost})", message.RequestId, message.GenerationDuration.TotalSeconds, message.Cost); try { - // Update task status to completed - var result = new + // Parse resolution to width/height if available + int width = 0, height = 0; + if (!string.IsNullOrEmpty(message.Resolution)) { - VideoUrl = message.VideoUrl, - PreviewUrl = message.PreviewUrl, - Duration = message.Duration, - Resolution = message.Resolution, - FileSize = message.FileSize, - Cost = message.Cost, - Provider = message.Provider, + var parts = message.Resolution.Split('x', 'X'); + if (parts.Length == 2) + { + int.TryParse(parts[0], out width); + int.TryParse(parts[1], out height); + } + } + + // Update task status to completed with VideoGenerationResponse format + // This matches what the SDK expects: { created, data: [{ url, metadata }], model } + var result = new VideoGenerationResponse + { + Created = new DateTimeOffset(message.CompletedAt).ToUnixTimeSeconds(), + Data = new List + { + new VideoData + { + Url = message.VideoUrl, + Metadata = new VideoMetadata + { + Width = width, + Height = height, + Duration = message.Duration, + FileSizeBytes = message.FileSize + } + } + }, Model = message.Model, - CompletedAt = message.CompletedAt + Usage = new VideoGenerationUsage + { + VideosGenerated = 1, + TotalDurationSeconds = message.Duration + } }; await _asyncTaskService.UpdateTaskStatusAsync( @@ -103,9 +129,9 @@ await _asyncTaskService.UpdateTaskStatusAsync( } // Clear progress cache for this task - var progressCacheKey = $"{ProgressCacheKeyPrefix}{message.RequestId}"; + var progressCacheKey = CacheKeys.MediaProgress.VideoProgress(message.RequestId); _progressCache.Remove(progressCacheKey); - + // Store completion info for analytics and audit var completionData = new { @@ -121,34 +147,31 @@ await _asyncTaskService.UpdateTaskStatusAsync( Cost = message.Cost, CompletedAt = message.CompletedAt }; - + // Cache completion data for recent tasks (24 hours) - UpdateCompletedTasksCache(completionData); - + MediaGenerationHandlerHelper.UpdateCompletedTasksCache(_progressCache, CompletedTasksCacheKey, completionData); + // Log performance metrics _logger.LogInformation("Video generation performance - Provider: {Provider}, Model: {Model}, Generation time: {GenerationTime}s, Video duration: {VideoDuration}s, Cost: ${Cost}", - message.Provider, message.Model, message.GenerationDuration.TotalSeconds, message.Duration, message.Cost); - + LoggingSanitizer.S(message.Provider), LoggingSanitizer.S(message.Model), message.GenerationDuration.TotalSeconds, message.Duration, message.Cost); + // Track provider-specific metrics LogProviderMetrics(message.Provider, message.Model, message.GenerationDuration, message.Duration, message.Cost); - - // Send completion notification via SignalR - await _hubContext.Clients.Group($"video-{message.RequestId}").SendAsync("VideoGenerationCompleted", new - { - taskId = message.RequestId, - status = "completed", - videoUrl = message.VideoUrl, - previewUrl = message.PreviewUrl, - duration = message.Duration, - resolution = message.Resolution, - fileSize = message.FileSize, - cost = message.Cost, - provider = message.Provider, - model = message.Model, - completedAt = message.CompletedAt, - generationDuration = message.GenerationDuration.TotalSeconds - }); - + + // Send completion notification via notification service + await _notificationService.NotifyVideoGenerationCompletedAsync( + message.RequestId, + message.VideoUrl, + message.GenerationDuration, + message.Cost, + previewUrl: message.PreviewUrl, + resolution: message.Resolution, + fileSize: message.FileSize, + provider: message.Provider, + model: message.Model, + completedAt: message.CompletedAt, + generationDurationSeconds: message.GenerationDuration.TotalSeconds); + _logger.LogInformation("Video generation completed for request {RequestId}", message.RequestId); } catch (Exception ex) @@ -158,24 +181,6 @@ await _asyncTaskService.UpdateTaskStatusAsync( } } - private void UpdateCompletedTasksCache(object completionData) - { - // Maintain a rolling list of recently completed tasks - var completedTasks = _progressCache.Get>(CompletedTasksCacheKey) ?? new List(); - - // Add new completion - completedTasks.Add(completionData); - - // Keep only last 100 completed tasks - if (completedTasks.Count() > 100) - { - completedTasks = completedTasks.Skip(completedTasks.Count() - 100).ToList(); - } - - // Cache for 24 hours - _progressCache.Set(CompletedTasksCacheKey, completedTasks, TimeSpan.FromHours(24)); - } - private void LogProviderMetrics(string provider, string model, TimeSpan generationDuration, double videoDuration, decimal cost) { // Log provider-specific metrics for monitoring and optimization @@ -189,8 +194,8 @@ private void LogProviderMetrics(string provider, string model, TimeSpan generati ["cost_per_second"] = videoDuration > 0 ? cost / (decimal)videoDuration : 0, ["generation_speed_ratio"] = generationDuration.TotalSeconds > 0 ? videoDuration / generationDuration.TotalSeconds : 0 }; - + _logger.LogInformation("Video generation metrics: {Metrics}", metrics); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationFailedHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationFailedHandler.cs index 5ebc8b066..d092964a6 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationFailedHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationFailedHandler.cs @@ -1,43 +1,44 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; -using ConduitLLM.Gateway.Hubs; +using ConduitLLM.Gateway.Interfaces; using MassTransit; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; namespace ConduitLLM.Gateway.EventHandlers { /// - /// Handles VideoGenerationFailed events to update task status and track failures. + /// Handles VideoGenerationFailed events to update task status, track failure metrics, and analyze error patterns. /// public class VideoGenerationFailedHandler : IConsumer { private readonly IAsyncTaskService _asyncTaskService; private readonly IMemoryCache _progressCache; - private readonly IHubContext _hubContext; + private readonly IVideoGenerationNotificationService _notificationService; private readonly ILogger _logger; - private const string ProgressCacheKeyPrefix = "video_generation_progress_"; + private const string FailureCountCacheKeyPrefix = "video_generation_failures_"; public VideoGenerationFailedHandler( IAsyncTaskService asyncTaskService, IMemoryCache progressCache, - IHubContext hubContext, + IVideoGenerationNotificationService notificationService, ILogger logger) { _asyncTaskService = asyncTaskService; _progressCache = progressCache; - _hubContext = hubContext; + _notificationService = notificationService; _logger = logger; } public async Task Consume(ConsumeContext context) { var message = context.Message; - - _logger.LogWarning("Video generation failed for request {RequestId}: {Error}", - message.RequestId, message.Error); + var provider = message.Provider ?? "unknown"; + + _logger.LogError("Video generation failed for request {RequestId}: {Error} (Provider: {Provider}, Retryable: {IsRetryable}, Retry: {RetryCount}/{MaxRetries})", + message.RequestId, message.Error, provider, message.IsRetryable, message.RetryCount, message.MaxRetries); try { @@ -45,7 +46,6 @@ public async Task Consume(ConsumeContext context) var taskStatus = await _asyncTaskService.GetTaskStatusAsync(message.RequestId, context.CancellationToken); if (taskStatus != null) { - // This is an async task, update its status var errorDetails = new { Error = message.Error, @@ -56,7 +56,7 @@ public async Task Consume(ConsumeContext context) }; await _asyncTaskService.UpdateTaskStatusAsync( - message.RequestId, + message.RequestId, TaskState.Failed, progress: null, errorDetails, @@ -65,48 +65,69 @@ await _asyncTaskService.UpdateTaskStatusAsync( } else { - // This is a sync task failure, just log it _logger.LogInformation("Sync video generation failed (no task record) for request {RequestId}", message.RequestId); } - + // Clear progress cache for this task - var progressCacheKey = $"{ProgressCacheKeyPrefix}{message.RequestId}"; + var progressCacheKey = CacheKeys.MediaProgress.VideoProgress(message.RequestId); _progressCache.Remove(progressCacheKey); - - // Log failure metrics for monitoring - LogFailureMetrics(message); - - // Send failure notification via SignalR - await _hubContext.Clients.Group($"video-{message.RequestId}").SendAsync("VideoGenerationFailed", new + + // Track per-provider failure count + var failureCount = MediaGenerationHandlerHelper.TrackFailureMetrics( + _progressCache, FailureCountCacheKeyPrefix, provider, "video", _logger); + + // Log structured metrics for monitoring/alerting pipelines + _logger.LogInformation("Video generation failure metrics: {@Metrics}", new { - taskId = message.RequestId, - status = "failed", - error = message.Error, - errorCode = message.ErrorCode, - isRetryable = message.IsRetryable, - retryCount = message.RetryCount, - maxRetries = message.MaxRetries, - nextRetryAt = message.NextRetryAt, - failedAt = message.FailedAt + RequestId = message.RequestId, + Provider = provider, + ErrorCode = message.ErrorCode ?? "unknown", + IsRetryable = message.IsRetryable, + FailedAt = message.FailedAt, + ErrorType = MediaGenerationHandlerHelper.DetermineErrorType(message.Error, message.ErrorCode), + ProviderFailureCount = failureCount }); - - // Determine if automatic retry should be attempted + + // Analyze error patterns for actionable diagnostics + MediaGenerationHandlerHelper.AnalyzeErrorPattern( + message.Error, message.RequestId, _logger, + VideoSpecificErrorPatterns); + + // Send failure notification via notification service + await _notificationService.NotifyVideoGenerationFailedAsync( + message.RequestId, + message.Error, + message.IsRetryable, + errorCode: message.ErrorCode, + retryCount: message.RetryCount, + maxRetries: message.MaxRetries, + nextRetryAt: message.NextRetryAt, + failedAt: message.FailedAt); + + // Log retry state or permanent failure if (message.IsRetryable) { - _logger.LogInformation("Video generation failure is retryable for request {RequestId} (Retry {RetryCount}/{MaxRetries})", + _logger.LogInformation("Video generation failure is retryable for request {RequestId} (Retry {RetryCount}/{MaxRetries})", message.RequestId, message.RetryCount, message.MaxRetries); - + if (message.NextRetryAt.HasValue) { - _logger.LogInformation("Video generation will be retried at {NextRetryAt} for request {RequestId}", + _logger.LogInformation("Video generation will be retried at {NextRetryAt} for request {RequestId}", message.NextRetryAt.Value, message.RequestId); } } else { - _logger.LogError("Video generation failed permanently for request {RequestId}: {Error}", + _logger.LogError("Video generation failed permanently for request {RequestId}: {Error}", message.RequestId, message.Error); } + + // Flag critical failures (auth, account, credits) for immediate attention + if (MediaGenerationHandlerHelper.IsCriticalFailure(message.Error)) + { + _logger.LogCritical("Critical video generation failure detected for provider {Provider}: {Error}", + provider, message.Error); + } } catch (Exception ex) { @@ -115,43 +136,16 @@ await _asyncTaskService.UpdateTaskStatusAsync( } } - private void LogFailureMetrics(VideoGenerationFailed failure) - { - var metrics = new - { - RequestId = failure.RequestId, - Provider = failure.Provider ?? "unknown", - ErrorCode = failure.ErrorCode ?? "unknown", - IsRetryable = failure.IsRetryable, - FailedAt = failure.FailedAt, - ErrorType = DetermineErrorType(failure.Error, failure.ErrorCode) - }; - - _logger.LogInformation("Video generation failure metrics: {Metrics}", metrics); - } - - private string DetermineErrorType(string error, string? errorCode) + /// + /// Video-specific error patterns beyond the common set. + /// + private static readonly Dictionary VideoSpecificErrorPatterns = new() { - // Categorize errors for better monitoring and alerting - if (string.IsNullOrEmpty(error)) - return "unknown"; - - var lowerError = error.ToLowerInvariant(); - - if (lowerError.Contains("rate limit") || lowerError.Contains("quota")) - return "rate_limit"; - if (lowerError.Contains("auth") || lowerError.Contains("unauthorized")) - return "authentication"; - if (lowerError.Contains("timeout")) - return "timeout"; - if (lowerError.Contains("invalid") || lowerError.Contains("bad request")) - return "validation"; - if (lowerError.Contains("not found")) - return "not_found"; - if (lowerError.Contains("server error") || lowerError.Contains("internal")) - return "server_error"; - - return "other"; - } + ["quota"] = "Provider quota exhausted - check account limits", + ["duration"] = "Requested video duration may exceed provider limits", + ["resolution"] = "Requested video resolution may not be supported", + ["codec"] = "Unsupported video codec or output format", + ["format"] = "Unsupported video format requested" + }; } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationProgressHandler.cs b/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationProgressHandler.cs index c12d6a031..4e93c53b0 100644 --- a/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationProgressHandler.cs +++ b/Services/ConduitLLM.Gateway/EventHandlers/VideoGenerationProgressHandler.cs @@ -1,10 +1,10 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; -using ConduitLLM.Gateway.Hubs; +using ConduitLLM.Gateway.Interfaces; using MassTransit; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; namespace ConduitLLM.Gateway.EventHandlers @@ -16,33 +16,32 @@ public class VideoGenerationProgressHandler : IConsumer { private readonly IAsyncTaskService _asyncTaskService; private readonly IMemoryCache _progressCache; - private readonly IHubContext _hubContext; + private readonly IVideoGenerationNotificationService _notificationService; private readonly ILogger _logger; - private const string ProgressCacheKeyPrefix = "video_generation_progress_"; public VideoGenerationProgressHandler( IAsyncTaskService asyncTaskService, IMemoryCache progressCache, - IHubContext hubContext, + IVideoGenerationNotificationService notificationService, ILogger logger) { _asyncTaskService = asyncTaskService; _progressCache = progressCache; - _hubContext = hubContext; + _notificationService = notificationService; _logger = logger; } public async Task Consume(ConsumeContext context) { var message = context.Message; - - _logger.LogDebug("Video generation progress for request {RequestId}: {Progress}% - {Status}", + + _logger.LogDebug("Video generation progress for request {RequestId}: {Progress}% - {Status}", message.RequestId, message.ProgressPercentage, message.Status); try { // Update progress cache for real-time queries - var cacheKey = $"{ProgressCacheKeyPrefix}{message.RequestId}"; + var cacheKey = CacheKeys.MediaProgress.VideoProgress(message.RequestId); var progressData = new { RequestId = message.RequestId, @@ -53,10 +52,10 @@ public async Task Consume(ConsumeContext context) TotalFrames = message.TotalFrames, LastUpdated = DateTime.UtcNow }; - + // Cache progress for 1 hour (long-running tasks) _progressCache.Set(cacheKey, progressData, TimeSpan.FromHours(1)); - + // Update task status with progress info var taskStatus = await _asyncTaskService.GetTaskStatusAsync(message.RequestId, context.CancellationToken); if (taskStatus != null) @@ -64,7 +63,7 @@ public async Task Consume(ConsumeContext context) // Update progress percentage and message taskStatus.Progress = message.ProgressPercentage; taskStatus.ProgressMessage = message.Message ?? message.Status; - + // Update metadata with detailed progress info if (taskStatus.Result is IDictionary resultDict) { @@ -74,30 +73,27 @@ public async Task Consume(ConsumeContext context) { taskStatus.Result = new Dictionary { ["progress"] = progressData }; } - + await _asyncTaskService.UpdateTaskStatusAsync( - message.RequestId, - TaskState.Processing, + message.RequestId, + TaskState.Processing, progress: message.ProgressPercentage, result: taskStatus.Result, error: null, cancellationToken: context.CancellationToken); } - + // Log significant progress milestones LogProgressMilestone(message); - - // Send real-time updates to WebAdmin via SignalR - await _hubContext.Clients.Group($"video-{message.RequestId}").SendAsync("VideoGenerationProgress", new - { - taskId = message.RequestId, - status = message.Status, - progress = message.ProgressPercentage, - message = message.Message, - framesCompleted = message.FramesCompleted, - totalFrames = message.TotalFrames, - timestamp = DateTime.UtcNow - }); + + // Send real-time updates to WebAdmin via notification service + await _notificationService.NotifyVideoGenerationProgressAsync( + message.RequestId, + message.ProgressPercentage, + message.Status, + message.Message, + message.FramesCompleted, + message.TotalFrames); } catch (Exception ex) { @@ -133,7 +129,7 @@ private void LogProgressMilestone(VideoGenerationProgress progress) { _logger.LogInformation("Video upload phase for request {RequestId}", progress.RequestId); } - + // Log frame progress if available if (progress.FramesCompleted.HasValue && progress.TotalFrames.HasValue) { @@ -142,4 +138,4 @@ private void LogProgressMilestone(VideoGenerationProgress progress) } } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Extensions/AuditServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/AuditServicesExtensions.cs new file mode 100644 index 000000000..12295de41 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/AuditServicesExtensions.cs @@ -0,0 +1,32 @@ +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Configuration.Services; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Functions.Interfaces; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering audit services +/// +public static class AuditServicesExtensions +{ + /// + /// Adds audit services including request log and function call audit services with leader election + /// + public static IServiceCollection AddAuditServices(this IServiceCollection services) + { + // Request Log Service - uses batch processing like other audit services + services.AddSingleton(); + services.AddLeaderElectedHostedService( + provider => (RequestLogService)provider.GetRequiredService(), + "RequestLogService"); + + // Register Function Call Audit service with leader election + services.AddSingleton(); + services.AddLeaderElectedHostedService( + provider => (FunctionCallAuditService)provider.GetRequiredService(), + "FunctionCallAuditService"); + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/BatchOperationServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/BatchOperationServicesExtensions.cs new file mode 100644 index 000000000..deeabc044 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/BatchOperationServicesExtensions.cs @@ -0,0 +1,41 @@ +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Configuration.Repositories; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; +using ConduitLLM.Core.Services.BatchOperations; +using ConduitLLM.Gateway.Services; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering batch operation services +/// +public static class BatchOperationServicesExtensions +{ + /// + /// Adds batch operation services including TaskHub, history, notification, and batch operations + /// + public static IServiceCollection AddBatchOperationServices(this IServiceCollection services) + { + // Register TaskHub Service for ITaskHub interface + services.AddSingleton(); + + // Register Batch Operation Services + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + + // Register Batch Operation Idempotency Service (Redis-based) + services.AddSingleton(); + + // Register batch operations + services.AddScoped(); + services.AddScoped(); + + // Register spend update batch operation + services.AddScoped(); + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/BillingServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/BillingServicesExtensions.cs new file mode 100644 index 000000000..bf4a6eb48 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/BillingServicesExtensions.cs @@ -0,0 +1,69 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Configuration.Services; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; +using ConduitLLM.Gateway.Services; +using Microsoft.EntityFrameworkCore; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering billing and pricing services +/// +public static class BillingServicesExtensions +{ + /// + /// Adds billing and pricing services including cost calculation, billing audit, and pricing rules engine + /// + public static IServiceCollection AddBillingAndPricingServices(this IServiceCollection services) + { + // Model costs tracking service with caching decorator pattern + services.AddScoped(); + services.AddScoped(provider => + { + var innerService = provider.GetRequiredService(); + var cacheManager = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + return new CachedModelCostService(innerService, cacheManager, logger); + }); + + // Cost calculation service + services.AddScoped(); + + // Tool cost calculation service for provider tool billing + // Singleton: uses IDbContextFactory for database access and optional IProviderToolCache + services.AddSingleton(sp => + { + var contextFactory = sp.GetRequiredService>(); + var logger = sp.GetRequiredService>(); + var cache = sp.GetService(); // Optional + return new ToolCostCalculationService(contextFactory, logger, cache); + }); + + // Ephemeral key service for direct browser-to-API authentication (used for all direct access including SignalR) + services.AddScoped(); + + // Billing audit service for comprehensive billing event tracking - with leader election + services.AddSingleton(); + services.AddLeaderElectedHostedService( + provider => (BillingAuditService)provider.GetRequiredService(), + "BillingAuditService"); + + // Pricing rules engine services for flexible rules-based pricing + services.AddScoped(); + services.AddScoped(); + + // Cached pricing rules service for parsed configuration caching (uses ICacheManager) + services.AddSingleton(); + + // Pricing audit service for rules-based pricing evaluation tracking - with leader election + services.AddSingleton(); + services.AddLeaderElectedHostedService( + provider => (PricingAuditService)provider.GetRequiredService(), + "PricingAuditService"); + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/DatabaseServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/DatabaseServicesExtensions.cs new file mode 100644 index 000000000..cb128d4d2 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/DatabaseServicesExtensions.cs @@ -0,0 +1,50 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Interceptors; +using ConduitLLM.Core.Data; +using Microsoft.EntityFrameworkCore; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering database services +/// +public static class DatabaseServicesExtensions +{ + /// + /// Adds database services including connection management, DbContext factory, and query monitoring + /// + public static IServiceCollection AddDatabaseServices(this IServiceCollection services, IConfiguration configuration) + { + // Get connection string from environment variables + var connectionStringManager = new ConnectionStringManager(); + // Pass "CoreAPI" to get Gateway API-specific connection pool settings + var (dbProvider, dbConnectionString) = connectionStringManager.GetProviderAndConnectionString("CoreAPI"); + + // Only PostgreSQL is supported + if (dbProvider != "postgres") + { + throw new InvalidOperationException($"Only PostgreSQL is supported. Invalid provider: {dbProvider}"); + } + + // Register DbContext Factory with query monitoring interceptor + services.AddDbContextFactory((sp, options) => + { + var interceptor = sp.GetRequiredService(); + options.UseNpgsql(dbConnectionString) + .AddInterceptors(interceptor); + }); + + // Also add scoped registration from factory for services that need direct injection + services.AddScoped(provider => + { + var factory = provider.GetService>(); + if (factory == null) + { + throw new InvalidOperationException("IDbContextFactory is not registered"); + } + return factory.CreateDbContext(); + }); + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/FunctionServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/FunctionServicesExtensions.cs new file mode 100644 index 000000000..5e8557787 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/FunctionServicesExtensions.cs @@ -0,0 +1,35 @@ +using ConduitLLM.Configuration.Repositories; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; +using ConduitLLM.Functions.Interfaces; +using ConduitLLM.Functions.Services; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering function services +/// +public static class FunctionServicesExtensions +{ + /// + /// Adds function services including repositories, execution, cost calculation, and agentic orchestration + /// + public static IServiceCollection AddFunctionServices(this IServiceCollection services) + { + // Register Function repositories + services.AddScoped(); + + // Register Function services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Register Agentic Function Calling services + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/HealthMonitoringExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/HealthMonitoringExtensions.cs index d878fae4b..b4cccf806 100644 --- a/Services/ConduitLLM.Gateway/Extensions/HealthMonitoringExtensions.cs +++ b/Services/ConduitLLM.Gateway/Extensions/HealthMonitoringExtensions.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Configuration.Options; using ConduitLLM.Gateway.Interfaces; using ConduitLLM.Gateway.Services; using ConduitLLM.Security.Interfaces; @@ -54,8 +55,6 @@ public static IServiceCollection AddHealthMonitoring(this IServiceCollection ser provider.GetRequiredService() as ConduitLLM.Security.Services.SecurityEventMonitoringService ?? throw new InvalidOperationException("SecurityEventMonitoringService not registered correctly")); - // System resources health check removed per YAGNI principle - // Register notification services services.Configure(configuration.GetSection("HealthMonitoring:Notifications")); services.Configure(configuration.GetSection("HealthMonitoring:Notifications:Webhook")); @@ -81,16 +80,5 @@ public static IServiceCollection AddHealthMonitoring(this IServiceCollection ser return services; } - /// - /// Adds advanced health monitoring checks (currently empty - removed unnecessary checks) - /// - public static IHealthChecksBuilder AddAdvancedHealthMonitoring( - this IHealthChecksBuilder healthChecksBuilder, - IConfiguration configuration) - { - // All advanced health checks have been removed per YAGNI principle - // Basic health checks are sufficient for monitoring service health - return healthChecksBuilder; - } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Extensions/HttpClientServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/HttpClientServicesExtensions.cs new file mode 100644 index 000000000..3b206b7e0 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/HttpClientServicesExtensions.cs @@ -0,0 +1,78 @@ +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Policies; +using ConduitLLM.Core.Services; +using Polly; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering HTTP client services with retry and circuit breaker policies +/// +public static class HttpClientServicesExtensions +{ + /// + /// Adds HTTP client services for image downloads, function providers, and file retrieval + /// + public static IServiceCollection AddHttpClientServices(this IServiceCollection services, IConfiguration configuration) + { + // Register shared HTTP clients (DiscoveryProviders, ImageDownload, Exa, Tavily) + services.AddSharedHttpClients(); + + // Register HTTP client for image downloads with retry policies (Gateway-specific: longer timeout, extended handler config) + services.AddHttpClient("ImageDownload", client => + { + client.Timeout = TimeSpan.FromSeconds(60); + client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM-ImageDownloader/1.0"); + client.DefaultRequestHeaders.Add("Accept", "image/*"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + MaxConnectionsPerServer = 20, + EnableMultipleHttp2Connections = true, + MaxResponseHeadersLength = 64 * 1024, + ResponseDrainTimeout = TimeSpan.FromSeconds(10), + ConnectTimeout = TimeSpan.FromSeconds(10), + AutomaticDecompression = System.Net.DecompressionMethods.All, + AllowAutoRedirect = true, + MaxAutomaticRedirections = 5 + }) + .AddPolicyHandler(HttpRetryPolicies.GetMediaDownloadRetryPolicy(backoffBase: 2, mediaType: "Image")) + .AddPolicyHandler(Policy.TimeoutAsync(TimeSpan.FromSeconds(120))); + + // Register HTTP client for video downloads with retry policies + services.AddHttpClient("VideoDownload", client => + { + client.Timeout = TimeSpan.FromMinutes(10); + client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM-VideoDownloader/1.0"); + client.DefaultRequestHeaders.Add("Accept", "video/*"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(10), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), + MaxConnectionsPerServer = 10, + EnableMultipleHttp2Connections = true, + MaxResponseHeadersLength = 64 * 1024, + ResponseDrainTimeout = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(30), + AutomaticDecompression = System.Net.DecompressionMethods.All, + AllowAutoRedirect = true, + MaxAutomaticRedirections = 5 + }) + .AddPolicyHandler(HttpRetryPolicies.GetMediaDownloadRetryPolicy(backoffBase: 3, mediaType: "Video")) + .AddPolicyHandler(Policy.TimeoutAsync(TimeSpan.FromMinutes(15))); + + // Register File Retrieval Service with retry-enabled HttpClient for resilient URL fetching + services.AddHttpClient() + .AddPolicyHandler(HttpRetryPolicies.GetStandardRetryPolicy()) + .ConfigureHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(60); + }); + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/MediaGenerationExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/MediaGenerationExtensions.cs new file mode 100644 index 000000000..c8d151bce --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/MediaGenerationExtensions.cs @@ -0,0 +1,44 @@ +using ConduitLLM.Core.Configuration; +using ConduitLLM.Core.Metrics; +using ConduitLLM.Core.Services; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering media generation services +/// +public static class MediaGenerationExtensions +{ + /// + /// Adds media generation services including video generation, retry configuration, metrics, and orchestrators + /// + public static IServiceCollection AddMediaGenerationServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment) + { + // Configure Video Generation Retry Settings + services.Configure(options => + { + options.MaxRetries = configuration.GetValue("VideoGeneration:MaxRetries", 3); + options.BaseDelaySeconds = configuration.GetValue("VideoGeneration:BaseDelaySeconds", 30); + options.MaxDelaySeconds = configuration.GetValue("VideoGeneration:MaxDelaySeconds", 3600); + options.EnableRetries = configuration.GetValue("VideoGeneration:EnableRetries", true); + options.RetryCheckIntervalSeconds = configuration.GetValue("VideoGeneration:RetryCheckIntervalSeconds", 30); + }); + + // Register Image Generation Retry Configuration + services.Configure( + configuration.GetSection("ConduitLLM:ImageGenerationRetry")); + + // Add background services for monitoring and cleanup (skip in test environment to prevent endless loops) + if (environment.EnvironmentName != "Test") + { + // Register media generation metrics + services.AddSingleton(); + + // Register media generation orchestrators + services.AddScoped(); + services.AddScoped(); + } + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/ObservabilityExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/ObservabilityExtensions.cs new file mode 100644 index 000000000..7bac76c5a --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/ObservabilityExtensions.cs @@ -0,0 +1,83 @@ +using ConduitLLM.Configuration.Interceptors; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering observability services (OpenTelemetry, metrics, tracing) +/// +public static class ObservabilityExtensions +{ + /// + /// Adds OpenTelemetry observability services including metrics, tracing, and query monitoring + /// + public static IServiceCollection AddObservabilityServices(this IServiceCollection services, IConfiguration configuration) + { + var otlpEndpoint = configuration["Telemetry:OtlpEndpoint"] ?? "http://localhost:4317"; + var tracingEnabled = configuration.GetValue("Telemetry:TracingEnabled", true); + + var otelBuilder = services.AddOpenTelemetry() + .WithMetrics(meterProviderBuilder => + { + meterProviderBuilder + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService(serviceName: "ConduitLLM.Gateway", serviceVersion: "1.0.0")) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddMeter("ConduitLLM.SignalR") + .AddMeter("ConduitLLM.MediaGeneration") + .AddMeter("ConduitLLM.Gateway.Requests") + .AddMeter("ConduitLLM.Providers") + .AddPrometheusExporter(); + }); + + // Add distributed tracing when enabled + if (tracingEnabled) + { + otelBuilder.WithTracing(tracerProviderBuilder => + { + tracerProviderBuilder + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService(serviceName: "ConduitLLM.Gateway", serviceVersion: "1.0.0")) + .AddAspNetCoreInstrumentation(options => + { + // Filter out health check endpoints to reduce noise + options.Filter = httpContext => + !httpContext.Request.Path.StartsWithSegments("/health") && + !httpContext.Request.Path.StartsWithSegments("/metrics"); + }) + .AddHttpClientInstrumentation() + .AddSqlClientInstrumentation(options => + { + options.RecordException = true; + }) + .AddRedisInstrumentation() + .AddSource("ConduitLLM.SignalR") + .AddSource("ConduitLLM.MediaGeneration") + .AddSource("ConduitLLM.Gateway.Requests") + .AddSource("ConduitLLM.Providers") + .AddOtlpExporter(options => + { + options.Endpoint = new Uri(otlpEndpoint); + }); + }); + } + + // Configure query monitoring for performance tracking + services.Configure( + configuration.GetSection(QueryMonitoringOptions.SectionName)); + services.AddSingleton(); + + // Register task processing metrics (per-instance) + services.AddHostedService(); + + // Note: BusinessMetricsService and GatewayOperationsMetricsService are registered + // in Program.Monitoring.cs with leader election to avoid duplicate metrics in scaled deployments + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/SecurityOptionsExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/SecurityOptionsExtensions.cs index c0736dbf1..a429fae16 100644 --- a/Services/ConduitLLM.Gateway/Extensions/SecurityOptionsExtensions.cs +++ b/Services/ConduitLLM.Gateway/Extensions/SecurityOptionsExtensions.cs @@ -1,114 +1,25 @@ -using ConduitLLM.Gateway.Options; +// Re-export the shared security options extension methods for Gateway API +// This file is a facade that delegates to the shared ConduitLLM.Security library +using ConduitLLM.Security.Options; namespace ConduitLLM.Gateway.Extensions { /// - /// Extension methods for configuring Gateway API security options + /// Extension methods for configuring Gateway security options. + /// Delegates to the shared ConduitLLM.Security.Options.SecurityOptionsExtensions. /// - public static class SecurityOptionsExtensions + public static class GatewaySecurityOptionsExtensions { /// - /// Configures Gateway API security options from configuration + /// Configures Gateway security options from environment variables. + /// This is a facade method that delegates to the shared implementation. /// public static IServiceCollection ConfigureCoreApiSecurityOptions( - this IServiceCollection services, + this IServiceCollection services, IConfiguration configuration) { - services.Configure(options => - { - // IP Filtering - options.IpFiltering.Enabled = configuration.GetValue("CONDUIT_CORE_IP_FILTERING_ENABLED") - ?? configuration.GetValue("CoreApi:Security:IpFiltering:Enabled", true); - - options.IpFiltering.Mode = configuration["CONDUIT_CORE_IP_FILTER_MODE"] - ?? configuration["CoreApi:Security:IpFiltering:Mode"] - ?? "permissive"; - - options.IpFiltering.AllowPrivateIps = configuration.GetValue("CONDUIT_CORE_IP_FILTER_ALLOW_PRIVATE") - ?? configuration.GetValue("CoreApi:Security:IpFiltering:AllowPrivateIps", true); - - // Parse whitelist - var whitelist = configuration["CONDUIT_CORE_IP_FILTER_WHITELIST"] - ?? configuration["CoreApi:Security:IpFiltering:Whitelist"]; - if (!string.IsNullOrEmpty(whitelist)) - { - options.IpFiltering.Whitelist = whitelist.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(ip => ip.Trim()) - .ToList(); - } - - // Parse blacklist - var blacklist = configuration["CONDUIT_CORE_IP_FILTER_BLACKLIST"] - ?? configuration["CoreApi:Security:IpFiltering:Blacklist"]; - if (!string.IsNullOrEmpty(blacklist)) - { - options.IpFiltering.Blacklist = blacklist.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(ip => ip.Trim()) - .ToList(); - } - - // Rate Limiting (IP-based) - options.RateLimiting.Enabled = configuration.GetValue("CONDUIT_CORE_RATE_LIMITING_ENABLED") - ?? configuration.GetValue("CoreApi:Security:RateLimiting:Enabled", true); - - options.RateLimiting.MaxRequests = configuration.GetValue("CONDUIT_CORE_RATE_LIMIT_MAX_REQUESTS") - ?? configuration.GetValue("CoreApi:Security:RateLimiting:MaxRequests", 1000); - - options.RateLimiting.WindowSeconds = configuration.GetValue("CONDUIT_CORE_RATE_LIMIT_WINDOW_SECONDS") - ?? configuration.GetValue("CoreApi:Security:RateLimiting:WindowSeconds", 60); - - // Parse excluded paths for rate limiting - var rateLimitExcluded = configuration["CONDUIT_CORE_RATE_LIMIT_EXCLUDED_PATHS"] - ?? configuration["CoreApi:Security:RateLimiting:ExcludedPaths"]; - if (!string.IsNullOrEmpty(rateLimitExcluded)) - { - options.RateLimiting.ExcludedPaths = rateLimitExcluded.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(path => path.Trim()) - .ToList(); - } - - // Failed Authentication Protection - options.FailedAuth.MaxAttempts = configuration.GetValue("CONDUIT_CORE_MAX_FAILED_AUTH_ATTEMPTS") - ?? configuration.GetValue("CoreApi:Security:FailedAuth:MaxAttempts", 10); - - options.FailedAuth.BanDurationMinutes = configuration.GetValue("CONDUIT_CORE_AUTH_BAN_DURATION_MINUTES") - ?? configuration.GetValue("CoreApi:Security:FailedAuth:BanDurationMinutes", 30); - - options.FailedAuth.TrackAcrossKeys = configuration.GetValue("CONDUIT_CORE_TRACK_FAILED_AUTH_ACROSS_KEYS") - ?? configuration.GetValue("CoreApi:Security:FailedAuth:TrackAcrossKeys", true); - - // Security Headers - options.Headers.XContentTypeOptions = configuration.GetValue("CONDUIT_CORE_SECURITY_HEADERS_CONTENT_TYPE") - ?? configuration.GetValue("CoreApi:Security:Headers:XContentTypeOptions", true); - - options.Headers.XXssProtection = configuration.GetValue("CONDUIT_CORE_SECURITY_HEADERS_XSS") - ?? configuration.GetValue("CoreApi:Security:Headers:XXssProtection", false); - - options.Headers.Hsts.Enabled = configuration.GetValue("CONDUIT_CORE_SECURITY_HEADERS_HSTS_ENABLED") - ?? configuration.GetValue("CoreApi:Security:Headers:Hsts:Enabled", true); - - options.Headers.Hsts.MaxAge = configuration.GetValue("CONDUIT_CORE_SECURITY_HEADERS_HSTS_MAX_AGE") - ?? configuration.GetValue("CoreApi:Security:Headers:Hsts:MaxAge", 31536000); - - // Distributed Tracking - options.UseDistributedTracking = configuration.GetValue("CONDUIT_SECURITY_USE_DISTRIBUTED_TRACKING") - ?? configuration.GetValue("Security:UseDistributedTracking", true); - - // Virtual Key Options - options.VirtualKey.EnforceRateLimits = configuration.GetValue("CONDUIT_CORE_ENFORCE_VKEY_RATE_LIMITS") - ?? configuration.GetValue("CoreApi:Security:VirtualKey:EnforceRateLimits", true); - - options.VirtualKey.EnforceBudgetLimits = configuration.GetValue("CONDUIT_CORE_ENFORCE_VKEY_BUDGETS") - ?? configuration.GetValue("CoreApi:Security:VirtualKey:EnforceBudgetLimits", true); - - options.VirtualKey.EnforceModelRestrictions = configuration.GetValue("CONDUIT_CORE_ENFORCE_VKEY_MODELS") - ?? configuration.GetValue("CoreApi:Security:VirtualKey:EnforceModelRestrictions", true); - - options.VirtualKey.ValidationCacheSeconds = configuration.GetValue("CONDUIT_CORE_VKEY_CACHE_SECONDS") - ?? configuration.GetValue("CoreApi:Security:VirtualKey:ValidationCacheSeconds", 60); - }); - - return services; + // Delegate to the shared implementation + return SecurityOptionsExtensions.ConfigureGatewaySecurityOptions(services, configuration); } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Extensions/ServiceCollectionExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/ServiceCollectionExtensions.cs index fa06401e8..20c274cad 100644 --- a/Services/ConduitLLM.Gateway/Extensions/ServiceCollectionExtensions.cs +++ b/Services/ConduitLLM.Gateway/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,6 @@ using ConduitLLM.Gateway.Interfaces; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; using ConduitLLM.Gateway.Services; -using ConduitLLM.Gateway.Options; +using ConduitLLM.Security.Options; namespace ConduitLLM.Gateway.Extensions { @@ -18,20 +16,14 @@ public static IServiceCollection AddCoreApiSecurity(this IServiceCollection serv { // Configure security options from environment variables services.ConfigureCoreApiSecurityOptions(configuration); - + // Note: Distributed cache should be registered in Program.cs before calling this method // to ensure proper Redis configuration for production environments - - // Register security service with factory to make distributed cache optional - services.AddSingleton(serviceProvider => - { - var options = serviceProvider.GetRequiredService>(); - var config = serviceProvider.GetRequiredService(); - var logger = serviceProvider.GetRequiredService>(); - var memoryCache = serviceProvider.GetRequiredService(); - - return new SecurityService(options, config, logger, memoryCache, serviceProvider); - }); + + // Register security service for both shared and gateway-specific interfaces + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); // Register IP filter service as scoped since it depends on scoped repository services.AddScoped(); diff --git a/Services/ConduitLLM.Gateway/Extensions/SignalRServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/SignalRServicesExtensions.cs new file mode 100644 index 000000000..e91d628bf --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/SignalRServicesExtensions.cs @@ -0,0 +1,41 @@ +using ConduitLLM.Gateway.Services; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering SignalR reliability services +/// +public static class SignalRServicesExtensions +{ + /// + /// Adds SignalR reliability services including acknowledgment, message queue, connection monitor, and batcher + /// + public static IServiceCollection AddSignalRReliabilityServices(this IServiceCollection services) + { + // Register SignalR acknowledgment service + services.AddSingleton(); + services.AddHostedService(provider => + (SignalRAcknowledgmentService)provider.GetRequiredService()); + + // Register SignalR message queue service + services.AddSingleton(); + services.AddHostedService(provider => + (SignalRMessageQueueService)provider.GetRequiredService()); + + // Register SignalR connection monitor + services.AddSingleton(); + services.AddHostedService(provider => + (SignalRConnectionMonitor)provider.GetRequiredService()); + + // Register SignalR message batcher + services.AddSingleton(); + services.AddHostedService(provider => + (SignalRMessageBatcher)provider.GetRequiredService()); + + // Register SignalR OpenTelemetry metrics + services.AddSingleton(); + services.AddHostedService(); + + return services; + } +} diff --git a/Services/ConduitLLM.Gateway/Extensions/WebhookServicesExtensions.cs b/Services/ConduitLLM.Gateway/Extensions/WebhookServicesExtensions.cs new file mode 100644 index 000000000..a2983faf2 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Extensions/WebhookServicesExtensions.cs @@ -0,0 +1,176 @@ +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; +using ConduitLLM.Gateway.Handlers; +using ConduitLLM.Gateway.Services; +using ConduitLLM.Gateway.Services.SpendNotification; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Caching.Memory; +using Polly; +using Polly.Extensions.Http; +using StackExchange.Redis; + +namespace ConduitLLM.Gateway.Extensions; + +/// +/// Extension methods for registering webhook-related services +/// +public static class WebhookServicesExtensions +{ + /// + /// Adds webhook services including delivery, metrics, connection tracking, and circuit breakers + /// + public static IServiceCollection AddWebhookServices(this IServiceCollection services, IConfiguration configuration) + { + // Register Webhook Delivery Service + services.AddSingleton(); + + // Register Distributed Spend Notification Service (Redis-based for multi-instance consistency) - with leader election + services.AddSingleton(); + services.AddLeaderElectedHostedService( + sp => (DistributedSpendNotificationService)sp.GetRequiredService(), + "SpendNotificationService"); + + // Register Webhook Metrics Service (Redis-based when available) + services.AddSingleton(sp => + { + var redis = sp.GetService(); + + if (redis != null) + { + var logger = sp.GetRequiredService>(); + return new ConduitLLM.Core.Services.RedisWebhookMetricsService(redis, logger); + } + + // Return null when Redis is not available - the notification service will handle fallback + return null!; + }); + + // Register Webhook Connection Tracker (Redis-based when available) + services.AddSingleton(sp => + { + var redis = sp.GetService(); + + if (redis != null) + { + var logger = sp.GetRequiredService>(); + return new ConduitLLM.Core.Services.RedisWebhookConnectionTracker(redis, logger); + } + else + { + // Fall back to in-memory tracker + var logger = sp.GetRequiredService>(); + return new ConduitLLM.Core.Services.InMemoryWebhookConnectionTracker(logger); + } + }); + + // Register Webhook Delivery Notification Service - with leader election + services.AddSingleton(sp => + { + var hubContext = sp.GetRequiredService>(); + var serviceProvider = sp; + var logger = sp.GetRequiredService>(); + return new WebhookDeliveryNotificationService(hubContext, serviceProvider, logger); + }); + services.AddLeaderElectedHostedService( + sp => (WebhookDeliveryNotificationService)sp.GetRequiredService(), + "WebhookDeliveryNotificationService"); + + // Register Webhook Circuit Breaker for preventing repeated failures + services.AddSingleton(sp => + { + var redis = sp.GetService(); + + if (redis != null) + { + // Use Redis-based distributed circuit breaker when available + var redisLogger = sp.GetRequiredService>(); + return new ConduitLLM.Core.Services.RedisWebhookCircuitBreaker( + redis, + redisLogger, + failureThreshold: 5, + openDuration: TimeSpan.FromMinutes(5), + halfOpenTestInterval: TimeSpan.FromSeconds(30)); + } + else + { + // Fall back to in-memory circuit breaker + var cache = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + + return new ConduitLLM.Core.Services.WebhookCircuitBreaker( + cache, + logger, + failureThreshold: 5, + openDuration: TimeSpan.FromMinutes(5), + counterResetDuration: TimeSpan.FromMinutes(15)); + } + }); + + // Register Webhook Notification Service with optimized configuration for high throughput + services.AddTransient(); + services.AddHttpClient( + "WebhookClient", + client => + { + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM/1.0"); + client.DefaultRequestHeaders.ConnectionClose = false; + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + MaxConnectionsPerServer = 100, + EnableMultipleHttp2Connections = true, + MaxResponseHeadersLength = 64 * 1024, + ResponseDrainTimeout = TimeSpan.FromSeconds(5), + ConnectTimeout = TimeSpan.FromSeconds(5), + KeepAlivePingTimeout = TimeSpan.FromSeconds(20), + KeepAlivePingDelay = TimeSpan.FromSeconds(30) + }) + .AddPolicyHandler((sp, _) => GetWebhookRetryPolicy(sp.GetRequiredService>())) + .AddPolicyHandler((sp, _) => GetWebhookCircuitBreakerPolicy(sp.GetRequiredService>())) + .AddHttpMessageHandler(); + + return services; + } + + /// + /// Polly retry policy for webhook delivery + /// + private static IAsyncPolicy GetWebhookRetryPolicy(ILogger logger) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => !msg.IsSuccessStatusCode && msg.StatusCode != System.Net.HttpStatusCode.BadRequest) + .WaitAndRetryAsync( + 3, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (outcome, timespan, retryCount, context) => + { + logger.LogWarning("Webhook retry attempt {RetryCount} after {DelayMs}ms. Status: {StatusCode}", + retryCount, timespan.TotalMilliseconds, outcome.Result?.StatusCode.ToString() ?? "N/A"); + }); + } + + /// + /// Polly circuit breaker policy for webhook delivery + /// + private static IAsyncPolicy GetWebhookCircuitBreakerPolicy(ILogger logger) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromMinutes(1), + onBreak: (result, duration) => + { + logger.LogWarning("Webhook circuit breaker opened for {DurationSeconds} seconds", duration.TotalSeconds); + }, + onReset: () => + { + logger.LogInformation("Webhook circuit breaker reset"); + }); + } +} diff --git a/Services/ConduitLLM.Gateway/Filters/SignalRMetricsFilter.cs b/Services/ConduitLLM.Gateway/Filters/SignalRMetricsFilter.cs index da10887cb..0f98e2556 100644 --- a/Services/ConduitLLM.Gateway/Filters/SignalRMetricsFilter.cs +++ b/Services/ConduitLLM.Gateway/Filters/SignalRMetricsFilter.cs @@ -2,6 +2,8 @@ using Microsoft.AspNetCore.SignalR; using ConduitLLM.Gateway.Interfaces; +using ConduitLLM.Gateway.Metrics; + namespace ConduitLLM.Gateway.Filters { /// @@ -135,6 +137,8 @@ public async Task OnDisconnectedAsync( "Invoking SignalR hub method {MethodName} on hub {HubName} for connection {ConnectionId}", methodName, hubName, connectionId); + using var activity = SignalRMetrics.StartMessageActivity( + $"SignalR.{hubName}.{methodName}", hubName, methodName); using var timer = _metrics.RecordHubMethodInvocation(hubName, methodName, virtualKeyId, protocol); try @@ -149,6 +153,8 @@ public async Task OnDisconnectedAsync( } catch (HubException hubEx) { + activity?.SetStatus(ActivityStatusCode.Error, hubEx.Message); + _logger.LogWarning(hubEx, "Hub exception in method {MethodName} on hub {HubName} for connection {ConnectionId}: {Message}", methodName, hubName, connectionId, hubEx.Message); @@ -165,6 +171,8 @@ public async Task OnDisconnectedAsync( } catch (Exception ex) { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + _logger.LogError(ex, "Error invoking SignalR hub method {MethodName} on hub {HubName} for connection {ConnectionId}", methodName, hubName, connectionId); diff --git a/Services/ConduitLLM.Gateway/Hubs/AcknowledgmentHub.cs b/Services/ConduitLLM.Gateway/Hubs/AcknowledgmentHub.cs index 30f83d551..cddf67e7e 100644 --- a/Services/ConduitLLM.Gateway/Hubs/AcknowledgmentHub.cs +++ b/Services/ConduitLLM.Gateway/Hubs/AcknowledgmentHub.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Models.SignalR; using ConduitLLM.Gateway.Models; using ConduitLLM.Gateway.Services; @@ -122,6 +123,7 @@ protected async Task SendToGroupWithAcknowledgmentAsync( public override async Task OnDisconnectedAsync(Exception? exception) { + Logger.LogDebug("Cleaning up acknowledgments for disconnected connection {ConnectionId}", Context.ConnectionId); await _acknowledgmentService.CleanupConnectionAsync(Context.ConnectionId); await base.OnDisconnectedAsync(exception); } diff --git a/Services/ConduitLLM.Gateway/Hubs/BaseHub.cs b/Services/ConduitLLM.Gateway/Hubs/BaseHub.cs deleted file mode 100644 index 43e76a7eb..000000000 --- a/Services/ConduitLLM.Gateway/Hubs/BaseHub.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.SignalR; - -using ConduitLLM.Gateway.Interfaces; -namespace ConduitLLM.Gateway.Hubs -{ - /// - /// Base class for all SignalR hubs that provides common functionality. - /// This base class is for hubs that do not require authentication. - /// - public abstract class BaseHub : Hub - { - protected readonly ILogger Logger; - private ISignalRMetrics? _metrics; - - protected BaseHub(ILogger logger) - { - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Gets the SignalR metrics instance, lazily initialized from DI. - /// - protected ISignalRMetrics? Metrics - { - get - { - if (_metrics == null && Context.GetHttpContext() != null) - { - _metrics = Context.GetHttpContext()!.RequestServices.GetService(); - } - return _metrics; - } - } - - public override async Task OnConnectedAsync() - { - var correlationId = GetOrCreateCorrelationId(); - - using (Logger.BeginScope(new Dictionary - { - ["ConnectionId"] = Context.ConnectionId, - ["HubName"] = GetHubName(), - ["CorrelationId"] = correlationId - })) - { - Logger.LogInformation("Client connected to {HubName}: {ConnectionId}", - GetHubName(), Context.ConnectionId); - - await OnClientConnectedAsync(); - await base.OnConnectedAsync(); - } - } - - public override async Task OnDisconnectedAsync(Exception? exception) - { - var correlationId = GetOrCreateCorrelationId(); - - using (Logger.BeginScope(new Dictionary - { - ["ConnectionId"] = Context.ConnectionId, - ["HubName"] = GetHubName(), - ["CorrelationId"] = correlationId - })) - { - if (exception != null) - { - Logger.LogWarning(exception, "Client disconnected from {HubName} with error: {ConnectionId}", - GetHubName(), Context.ConnectionId); - } - else - { - Logger.LogInformation("Client disconnected from {HubName}: {ConnectionId}", - GetHubName(), Context.ConnectionId); - } - - await OnClientDisconnectedAsync(exception); - await base.OnDisconnectedAsync(exception); - } - } - - /// - /// Called when a client successfully connects. Override to implement hub-specific logic. - /// - protected virtual Task OnClientConnectedAsync() - { - return Task.CompletedTask; - } - - /// - /// Called when a client disconnects. Override to implement hub-specific cleanup. - /// - protected virtual Task OnClientDisconnectedAsync(Exception? exception) - { - return Task.CompletedTask; - } - - /// - /// Gets the name of the hub for logging purposes - /// - protected abstract string GetHubName(); - - /// - /// Adds the current connection to a named group. - /// - /// The name of the group - protected async Task AddToGroupAsync(string groupName) - { - await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - - Logger.LogDebug("Added connection {ConnectionId} to group {GroupName} in {HubName}", - Context.ConnectionId, groupName, GetHubName()); - - Metrics?.GroupJoins.Add(1, new TagList - { - { "hub", GetHubName() }, - { "group", groupName } - }); - } - - /// - /// Removes the current connection from a named group. - /// - /// The name of the group - protected async Task RemoveFromGroupAsync(string groupName) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); - - Logger.LogDebug("Removed connection {ConnectionId} from group {GroupName} in {HubName}", - Context.ConnectionId, groupName, GetHubName()); - - Metrics?.GroupLeaves.Add(1, new TagList - { - { "hub", GetHubName() }, - { "group", groupName } - }); - } - - /// - /// Gets or creates a correlation ID for the current connection. - /// - protected string GetOrCreateCorrelationId() - { - if (Context.Items.TryGetValue("CorrelationId", out var value) && value is string correlationId) - { - return correlationId; - } - - correlationId = Guid.NewGuid().ToString(); - Context.Items["CorrelationId"] = correlationId; - return correlationId; - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Hubs/EnhancedVideoGenerationHub.cs b/Services/ConduitLLM.Gateway/Hubs/EnhancedVideoGenerationHub.cs index 25950f77e..134f48aaa 100644 --- a/Services/ConduitLLM.Gateway/Hubs/EnhancedVideoGenerationHub.cs +++ b/Services/ConduitLLM.Gateway/Hubs/EnhancedVideoGenerationHub.cs @@ -73,6 +73,9 @@ public async Task UnsubscribeFromTask(string taskId) /// public async Task SendTaskProgressWithAck(string taskId, int progress, string status) { + _logger.LogDebug("Sending acknowledged progress for video task {TaskId}: {Progress}% - {Status}", + taskId, progress, status); + var message = new TaskProgressMessage { TaskId = taskId, @@ -94,6 +97,16 @@ await SendToGroupWithAcknowledgmentAsync( /// public async Task SendTaskCompletedWithAck(string taskId, bool success, object? result, string? error) { + if (success) + { + _logger.LogInformation("Sending acknowledged completion for video task {TaskId}: succeeded", taskId); + } + else + { + _logger.LogWarning("Sending acknowledged completion for video task {TaskId}: failed - {Error}", + taskId, error); + } + var message = new TaskCompletedMessage { TaskId = taskId, diff --git a/Services/ConduitLLM.Gateway/Hubs/HealthMonitoringHub.cs b/Services/ConduitLLM.Gateway/Hubs/HealthMonitoringHub.cs index a4625a199..d194d13ad 100644 --- a/Services/ConduitLLM.Gateway/Hubs/HealthMonitoringHub.cs +++ b/Services/ConduitLLM.Gateway/Hubs/HealthMonitoringHub.cs @@ -67,17 +67,29 @@ public async IAsyncEnumerable StreamHealthUpdates( public ChannelReader StreamAlerts(CancellationToken cancellationToken = default) { var channel = Channel.CreateUnbounded(); - + _ = Task.Run(async () => { - await foreach (var alert in _alertManagementService.GetAlertStreamAsync(cancellationToken)) + try { - await channel.Writer.WriteAsync(alert, cancellationToken); + await foreach (var alert in _alertManagementService.GetAlertStreamAsync(cancellationToken)) + { + await channel.Writer.WriteAsync(alert, cancellationToken); + } + + channel.Writer.Complete(); + } + catch (OperationCanceledException) + { + channel.Writer.TryComplete(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error streaming alerts to client {ConnectionId}", Context.ConnectionId); + channel.Writer.TryComplete(ex); } - - channel.Writer.Complete(); }, cancellationToken); - + return channel.Reader; } diff --git a/Services/ConduitLLM.Gateway/Hubs/ImageGenerationHub.cs b/Services/ConduitLLM.Gateway/Hubs/ImageGenerationHub.cs index b77e5d271..06b1c9171 100644 --- a/Services/ConduitLLM.Gateway/Hubs/ImageGenerationHub.cs +++ b/Services/ConduitLLM.Gateway/Hubs/ImageGenerationHub.cs @@ -1,59 +1,20 @@ -using Microsoft.AspNetCore.SignalR; -using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Constants; namespace ConduitLLM.Gateway.Hubs { /// - /// SignalR hub for real-time image generation status updates + /// SignalR hub for real-time image generation status updates. /// - public class ImageGenerationHub : SecureHub + public class ImageGenerationHub : TaskSubscriptionHub { - private readonly IAsyncTaskService _taskService; - public ImageGenerationHub( ILogger logger, - IAsyncTaskService taskService, IServiceProvider serviceProvider) : base(logger, serviceProvider) { - _taskService = taskService ?? throw new ArgumentNullException(nameof(taskService)); } - protected override string GetHubName() => "ImageGenerationHub"; - - /// - /// Subscribe to updates for a specific image generation task - /// - public async Task SubscribeToTask(string taskId) - { - var virtualKeyId = RequireVirtualKeyId(); - var groupName = SignalRConstants.Groups.ImageTask(taskId); - - Logger.LogInformation("SubscribeToTask called - VirtualKeyId: {KeyId}, TaskId: {TaskId}, GroupName: {GroupName}, ConnectionId: {ConnectionId}", - virtualKeyId, taskId, groupName, Context.ConnectionId); - - // Verify task ownership using the base class method - if (!await CanAccessTaskAsync(taskId)) - { - Logger.LogWarning("Virtual Key {KeyId} attempted to subscribe to unauthorized task {TaskId}", - virtualKeyId, taskId); - throw new HubException("Unauthorized access to task"); - } - - await Groups.AddToGroupAsync(Context.ConnectionId, groupName); - Logger.LogInformation("Virtual Key {KeyId} successfully subscribed to image task {TaskId} in group {GroupName}, ConnectionId: {ConnectionId}", - virtualKeyId, taskId, groupName, Context.ConnectionId); - } - - /// - /// Unsubscribe from updates for a specific image generation task - /// - public async Task UnsubscribeFromTask(string taskId) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, SignalRConstants.Groups.ImageTask(taskId)); - Logger.LogDebug("Client {ConnectionId} unsubscribed from image task {TaskId}", - Context.ConnectionId, taskId); - } + protected override string GetHubName() => "ImageGeneration"; + protected override string GetTaskGroupName(string taskId) => SignalRConstants.Groups.ImageTask(taskId); } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Hubs/TaskHub.cs b/Services/ConduitLLM.Gateway/Hubs/TaskHub.cs index 9de56599c..f6547f70e 100644 --- a/Services/ConduitLLM.Gateway/Hubs/TaskHub.cs +++ b/Services/ConduitLLM.Gateway/Hubs/TaskHub.cs @@ -111,56 +111,71 @@ public async Task UnsubscribeFromTaskType(string taskType) public async Task TaskStarted(string taskId, string taskType, object metadata) { int? virtualKeyId = null; - + // Handle both TaskMetadata and IDictionary formats if (metadata is TaskMetadata taskMetadata) { virtualKeyId = taskMetadata.VirtualKeyId; } - else if (metadata is IDictionary metadataDict && + else if (metadata is IDictionary metadataDict && metadataDict.TryGetValue("virtualKeyId", out var virtualKeyIdObj)) { virtualKeyId = TaskHub.ConvertToInt(virtualKeyIdObj); } - + if (virtualKeyId.HasValue) { + Logger.LogInformation("Task {TaskId} started: type={TaskType}, virtualKey={VirtualKeyId}", + taskId, taskType, virtualKeyId); + // Notify specific task subscribers await _hubContext.Clients.Group($"task-{taskId}") .SendAsync("TaskStarted", taskId, taskType, metadata); - + // Notify task type subscribers for this virtual key await _hubContext.Clients.Group($"vkey-{virtualKeyId}-{taskType}") .SendAsync("TaskStarted", taskId, taskType, metadata); } + else + { + Logger.LogWarning("Task {TaskId} started but no virtual key ID could be extracted from metadata", taskId); + } } public async Task TaskProgress(string taskId, int progress, string? message = null) { + Logger.LogDebug("Task {TaskId} progress: {Progress}%{Message}", + taskId, progress, message != null ? $" - {message}" : ""); await _hubContext.Clients.Group($"task-{taskId}") .SendAsync("TaskProgress", taskId, progress, message); } public async Task TaskCompleted(string taskId, object result) { + Logger.LogInformation("Task {TaskId} completed successfully", taskId); await _hubContext.Clients.Group($"task-{taskId}") .SendAsync("TaskCompleted", taskId, result); } public async Task TaskFailed(string taskId, string error, bool isRetryable = false) { + Logger.LogWarning("Task {TaskId} failed (retryable: {IsRetryable}): {Error}", + taskId, isRetryable, error); await _hubContext.Clients.Group($"task-{taskId}") .SendAsync("TaskFailed", taskId, error, isRetryable); } public async Task TaskCancelled(string taskId, string? reason = null) { + Logger.LogInformation("Task {TaskId} cancelled{Reason}", + taskId, reason != null ? $": {reason}" : ""); await _hubContext.Clients.Group($"task-{taskId}") .SendAsync("TaskCancelled", taskId, reason); } public async Task TaskTimedOut(string taskId, int timeoutSeconds) { + Logger.LogWarning("Task {TaskId} timed out after {TimeoutSeconds}s", taskId, timeoutSeconds); await _hubContext.Clients.Group($"task-{taskId}") .SendAsync("TaskTimedOut", taskId, timeoutSeconds); } diff --git a/Services/ConduitLLM.Gateway/Hubs/TaskSubscriptionHub.cs b/Services/ConduitLLM.Gateway/Hubs/TaskSubscriptionHub.cs new file mode 100644 index 000000000..57115854e --- /dev/null +++ b/Services/ConduitLLM.Gateway/Hubs/TaskSubscriptionHub.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.SignalR; + +namespace ConduitLLM.Gateway.Hubs +{ + /// + /// Base class for media generation hubs that support task subscription. + /// Provides standardized subscribe/unsubscribe with ownership validation. + /// + public abstract class TaskSubscriptionHub : SecureHub + { + protected TaskSubscriptionHub( + ILogger logger, + IServiceProvider serviceProvider) + : base(logger, serviceProvider) + { + } + + /// + /// Gets the SignalR group name for a given task ID. + /// + protected abstract string GetTaskGroupName(string taskId); + + /// + /// Subscribe to updates for a specific generation task. + /// Validates virtual key ownership before subscribing. + /// + public async Task SubscribeToTask(string taskId) + { + var virtualKeyId = RequireVirtualKeyId(); + var groupName = GetTaskGroupName(taskId); + + if (!await CanAccessTaskAsync(taskId)) + { + Logger.LogWarning("Virtual Key {KeyId} attempted to subscribe to unauthorized task {TaskId}", + virtualKeyId, taskId); + throw new HubException("Unauthorized access to task"); + } + + await Groups.AddToGroupAsync(Context.ConnectionId, groupName); + Logger.LogInformation( + "Virtual Key {KeyId} subscribed to {HubName} task {TaskId} in group {GroupName}, ConnectionId: {ConnectionId}", + virtualKeyId, GetHubName(), taskId, groupName, Context.ConnectionId); + } + + /// + /// Unsubscribe from updates for a specific generation task. + /// + public async Task UnsubscribeFromTask(string taskId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, GetTaskGroupName(taskId)); + Logger.LogDebug("Client {ConnectionId} unsubscribed from {HubName} task {TaskId}", + Context.ConnectionId, GetHubName(), taskId); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Hubs/VideoGenerationHub.cs b/Services/ConduitLLM.Gateway/Hubs/VideoGenerationHub.cs index 52f4d86c9..9cdae36fb 100644 --- a/Services/ConduitLLM.Gateway/Hubs/VideoGenerationHub.cs +++ b/Services/ConduitLLM.Gateway/Hubs/VideoGenerationHub.cs @@ -1,55 +1,20 @@ -using Microsoft.AspNetCore.SignalR; -using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Constants; namespace ConduitLLM.Gateway.Hubs { /// - /// SignalR hub for real-time video generation status updates + /// SignalR hub for real-time video generation status updates. /// - public class VideoGenerationHub : SecureHub + public class VideoGenerationHub : TaskSubscriptionHub { - private readonly IAsyncTaskService _taskService; - public VideoGenerationHub( ILogger logger, - IAsyncTaskService taskService, IServiceProvider serviceProvider) : base(logger, serviceProvider) { - _taskService = taskService ?? throw new ArgumentNullException(nameof(taskService)); } - protected override string GetHubName() => "VideoGenerationHub"; - - /// - /// Subscribe to updates for a specific video generation task - /// - public async Task SubscribeToTask(string taskId) - { - var virtualKeyId = RequireVirtualKeyId(); - - // Verify task ownership using the base class method - if (!await CanAccessTaskAsync(taskId)) - { - Logger.LogWarning("Virtual Key {KeyId} attempted to subscribe to unauthorized task {TaskId}", - virtualKeyId, taskId); - throw new HubException("Unauthorized access to task"); - } - - await Groups.AddToGroupAsync(Context.ConnectionId, SignalRConstants.Groups.VideoTask(taskId)); - Logger.LogDebug("Virtual Key {KeyId} subscribed to video task {TaskId}", - virtualKeyId, taskId); - } - - /// - /// Unsubscribe from updates for a specific video generation task - /// - public async Task UnsubscribeFromTask(string taskId) - { - await Groups.RemoveFromGroupAsync(Context.ConnectionId, SignalRConstants.Groups.VideoTask(taskId)); - Logger.LogDebug("Client {ConnectionId} unsubscribed from video task {TaskId}", - Context.ConnectionId, taskId); - } + protected override string GetHubName() => "VideoGeneration"; + protected override string GetTaskGroupName(string taskId) => SignalRConstants.Groups.VideoTask(taskId); } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Interfaces/IContentGenerationNotificationService.cs b/Services/ConduitLLM.Gateway/Interfaces/IContentGenerationNotificationService.cs deleted file mode 100644 index 113ec7f07..000000000 --- a/Services/ConduitLLM.Gateway/Interfaces/IContentGenerationNotificationService.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace ConduitLLM.Gateway.Interfaces -{ - /// - /// Unified interface for content generation notifications. - /// Handles both image and video generation notifications through a single service. - /// - public interface IContentGenerationNotificationService - { - // Image Generation Events - - /// - /// Notifies that an image generation task has started. - /// - Task NotifyImageGenerationStartedAsync(string taskId, string prompt, int numberOfImages, string size, string? style = null); - - /// - /// Notifies progress update for an image generation task. - /// - Task NotifyImageGenerationProgressAsync(string taskId, int progressPercentage, string status, int imagesCompleted, int totalImages, string? message = null); - - /// - /// Notifies that an image generation task has completed successfully. - /// - Task NotifyImageGenerationCompletedAsync(string taskId, string[] imageUrls, TimeSpan duration, decimal cost); - - /// - /// Notifies that an image generation task has failed. - /// - Task NotifyImageGenerationFailedAsync(string taskId, string error, bool isRetryable); - - /// - /// Notifies that an image generation task has been cancelled. - /// - Task NotifyImageGenerationCancelledAsync(string taskId, string? reason); - - // Video Generation Events - - /// - /// Notifies that a video generation task has started. - /// - Task NotifyVideoGenerationStartedAsync(string taskId, string provider, DateTime startedAt, int? estimatedSeconds); - - /// - /// Notifies progress update for a video generation task. - /// - Task NotifyVideoGenerationProgressAsync(string taskId, int progressPercentage, string status, string? message = null); - - /// - /// Notifies that a video generation task has completed successfully. - /// - Task NotifyVideoGenerationCompletedAsync(string taskId, string videoUrl, TimeSpan duration, decimal cost); - - /// - /// Notifies that a video generation task has failed. - /// - Task NotifyVideoGenerationFailedAsync(string taskId, string error, bool isRetryable); - - /// - /// Notifies that a video generation task has been cancelled. - /// - Task NotifyVideoGenerationCancelledAsync(string taskId, string? reason); - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Interfaces/IVideoGenerationNotificationService.cs b/Services/ConduitLLM.Gateway/Interfaces/IVideoGenerationNotificationService.cs index dad653fd9..02e7d8fa0 100644 --- a/Services/ConduitLLM.Gateway/Interfaces/IVideoGenerationNotificationService.cs +++ b/Services/ConduitLLM.Gateway/Interfaces/IVideoGenerationNotificationService.cs @@ -13,17 +13,17 @@ public interface IVideoGenerationNotificationService /// /// Notify clients of video generation progress /// - Task NotifyVideoGenerationProgressAsync(string requestId, int progressPercentage, string status, string? message = null); + Task NotifyVideoGenerationProgressAsync(string requestId, int progressPercentage, string status, string? message = null, int? framesCompleted = null, int? totalFrames = null); /// /// Notify clients that video generation has completed /// - Task NotifyVideoGenerationCompletedAsync(string requestId, string videoUrl, TimeSpan duration, decimal cost); + Task NotifyVideoGenerationCompletedAsync(string requestId, string videoUrl, TimeSpan duration, decimal cost, string? previewUrl = null, string? resolution = null, long? fileSize = null, string? provider = null, string? model = null, DateTime? completedAt = null, double? generationDurationSeconds = null); /// /// Notify clients that video generation has failed /// - Task NotifyVideoGenerationFailedAsync(string requestId, string error, bool isRetryable); + Task NotifyVideoGenerationFailedAsync(string requestId, string error, bool isRetryable, string? errorCode = null, int? retryCount = null, int? maxRetries = null, DateTime? nextRetryAt = null, DateTime? failedAt = null); /// /// Notify clients that video generation was cancelled diff --git a/Services/ConduitLLM.Gateway/Interfaces/ImageGenerationNotificationService.cs b/Services/ConduitLLM.Gateway/Interfaces/ImageGenerationNotificationService.cs deleted file mode 100644 index 62194d7fc..000000000 --- a/Services/ConduitLLM.Gateway/Interfaces/ImageGenerationNotificationService.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Microsoft.AspNetCore.SignalR; -using ConduitLLM.Gateway.Hubs; -using ConduitLLM.Core.Constants; -namespace ConduitLLM.Gateway.Interfaces -{ - /// - /// Implementation of image generation notification service using SignalR - /// - public class ImageGenerationNotificationService : IImageGenerationNotificationService - { - private readonly IHubContext _hubContext; - private readonly ILogger _logger; - - public ImageGenerationNotificationService( - IHubContext hubContext, - ILogger logger) - { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task NotifyImageGenerationStartedAsync(string taskId, string prompt, int numberOfImages, string size, string? style = null) - { - try - { - await _hubContext.Clients.Group(SignalRConstants.Groups.ImageTask(taskId)).SendAsync(SignalRConstants.ClientMethods.ImageGenerationStarted, new - { - taskId, - prompt, - numberOfImages, - size, - style, - startedAt = DateTime.UtcNow - }); - - _logger.LogInformation( - "[SignalR:ImageGenerationStarted] Sent notification - TaskId: {TaskId}, Prompt: {Prompt}, NumberOfImages: {NumberOfImages}, Size: {Size}, Style: {Style}, Group: {Group}", - taskId, prompt.Length > 50 ? prompt.Substring(0, 50) + "..." : prompt, numberOfImages, size, style ?? "default", SignalRConstants.Groups.ImageTask(taskId)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationStarted notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationProgressAsync(string taskId, int progressPercentage, string status, int imagesCompleted, int totalImages, string? message = null) - { - try - { - await _hubContext.Clients.Group(SignalRConstants.Groups.ImageTask(taskId)).SendAsync(SignalRConstants.ClientMethods.ImageGenerationProgress, new - { - taskId, - progressPercentage, - status, - imagesCompleted, - totalImages, - message, - timestamp = DateTime.UtcNow - }); - - _logger.LogDebug("Sent ImageGenerationProgress notification for task {TaskId}: {Progress}% ({ImagesCompleted}/{TotalImages})", - taskId, progressPercentage, imagesCompleted, totalImages); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationProgress notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationCompletedAsync(string taskId, string[] imageUrls, TimeSpan duration, decimal cost) - { - try - { - var groupName = SignalRConstants.Groups.ImageTask(taskId); - _logger.LogInformation("NotifyImageGenerationCompletedAsync called for task {TaskId}, sending to group {GroupName}", taskId, groupName); - - await _hubContext.Clients.Group(groupName).SendAsync(SignalRConstants.ClientMethods.ImageGenerationCompleted, new - { - taskId, - imageUrls, - durationSeconds = duration.TotalSeconds, - cost, - completedAt = DateTime.UtcNow - }); - - _logger.LogInformation("Successfully sent ImageGenerationCompleted notification for task {TaskId} with {ImageCount} images to group {GroupName}", - taskId, imageUrls.Length, groupName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationCompleted notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationFailedAsync(string taskId, string error, bool isRetryable) - { - try - { - await _hubContext.Clients.Group(SignalRConstants.Groups.ImageTask(taskId)).SendAsync(SignalRConstants.ClientMethods.ImageGenerationFailed, new - { - taskId, - error, - isRetryable, - failedAt = DateTime.UtcNow - }); - - _logger.LogDebug("Sent ImageGenerationFailed notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationFailed notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationCancelledAsync(string taskId, string? reason) - { - try - { - await _hubContext.Clients.Group(SignalRConstants.Groups.ImageTask(taskId)).SendAsync(SignalRConstants.ClientMethods.ImageGenerationCancelled, new - { - taskId, - reason, - cancelledAt = DateTime.UtcNow - }); - - _logger.LogDebug("Sent ImageGenerationCancelled notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationCancelled notification for task {TaskId}", taskId); - } - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Metrics/GatewayAuthMetrics.cs b/Services/ConduitLLM.Gateway/Metrics/GatewayAuthMetrics.cs new file mode 100644 index 000000000..909a31242 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Metrics/GatewayAuthMetrics.cs @@ -0,0 +1,24 @@ +using ConduitLLM.Core.Metrics; +using Prometheus; + +namespace ConduitLLM.Gateway.Metrics +{ + /// + /// Prometheus metrics for Gateway authentication operations. + /// Delegates to shared with "gateway" prefix. + /// + public static class GatewayAuthMetrics + { + private static readonly AuthMetrics Instance = new("gateway"); + + public static Counter AuthAttempts => Instance.AuthAttempts; + public static Histogram AuthDuration => Instance.AuthDuration; + public static Counter AuthFailures => Instance.AuthFailures; + + public static void RecordSuccess(string scheme) => Instance.RecordSuccess(scheme); + public static void RecordFailure(string scheme, string reason) => Instance.RecordFailure(scheme, reason); + public static void RecordNoResult(string scheme) => Instance.RecordNoResult(scheme); + public static void RecordError(string scheme) => Instance.RecordError(scheme); + public static void RecordDuration(string scheme, double durationSeconds) => Instance.RecordDuration(scheme, durationSeconds); + } +} diff --git a/Services/ConduitLLM.Gateway/Metrics/GatewayCacheMetrics.cs b/Services/ConduitLLM.Gateway/Metrics/GatewayCacheMetrics.cs new file mode 100644 index 000000000..a34373ec6 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Metrics/GatewayCacheMetrics.cs @@ -0,0 +1,25 @@ +using ConduitLLM.Core.Metrics; +using Prometheus; + +namespace ConduitLLM.Gateway.Metrics +{ + /// + /// Prometheus metrics for Gateway cache operations (Redis and in-memory). + /// Delegates to shared with "gateway" prefix. + /// + public static class GatewayCacheMetrics + { + private static readonly CacheMetrics Instance = new("gateway"); + + public static Counter CacheLookups => Instance.CacheLookups; + public static Histogram CacheLatency => Instance.CacheLatency; + public static Counter CacheInvalidations => Instance.CacheInvalidations; + public static Counter CacheErrors => Instance.CacheErrors; + + public static void RecordHit(string cacheName) => Instance.RecordHit(cacheName); + public static void RecordMiss(string cacheName) => Instance.RecordMiss(cacheName); + public static void RecordLatency(string cacheName, string operation, double durationSeconds) => Instance.RecordLatency(cacheName, operation, durationSeconds); + public static void RecordInvalidation(string cacheName, string reason = "explicit") => Instance.RecordInvalidation(cacheName, reason); + public static void RecordError(string cacheName, string operation) => Instance.RecordError(cacheName, operation); + } +} diff --git a/Services/ConduitLLM.Gateway/Metrics/GatewayRateLimitMetrics.cs b/Services/ConduitLLM.Gateway/Metrics/GatewayRateLimitMetrics.cs new file mode 100644 index 000000000..6af01dca9 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Metrics/GatewayRateLimitMetrics.cs @@ -0,0 +1,23 @@ +using Prometheus; + +namespace ConduitLLM.Gateway.Metrics +{ + /// + /// Prometheus metrics for Gateway virtual-key rate limiting. + /// + public static class GatewayRateLimitMetrics + { + private static readonly Counter Decisions = Prometheus.Metrics.CreateCounter( + "conduit_gateway_rate_limit_decisions_total", + "Number of rate-limit decisions by outcome", + new CounterConfiguration { LabelNames = new[] { "outcome", "scope" } }); + + private static readonly Counter Errors = Prometheus.Metrics.CreateCounter( + "conduit_gateway_rate_limit_errors_total", + "Number of rate-limit check errors (Redis unavailable, etc.) โ€” these fail open"); + + public static void RecordAllowed(string scope) => Decisions.WithLabels("allowed", string.IsNullOrEmpty(scope) ? "none" : scope).Inc(); + public static void RecordRejected(string scope) => Decisions.WithLabels("rejected", string.IsNullOrEmpty(scope) ? "none" : scope).Inc(); + public static void RecordError() => Errors.Inc(); + } +} diff --git a/Services/ConduitLLM.Gateway/Metrics/GatewayRequestMetrics.cs b/Services/ConduitLLM.Gateway/Metrics/GatewayRequestMetrics.cs new file mode 100644 index 000000000..ae359c032 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Metrics/GatewayRequestMetrics.cs @@ -0,0 +1,96 @@ +using System.Diagnostics; + +namespace ConduitLLM.Gateway.Metrics; + +/// +/// Distributed tracing activity source for Gateway API request processing. +/// Provides spans for chat completions, image/video generation, embeddings, +/// and authentication flows to enable end-to-end trace visualization. +/// +public static class GatewayRequestMetrics +{ + /// + /// Activity source for Gateway request processing spans. + /// + public static readonly ActivitySource ActivitySource = new("ConduitLLM.Gateway.Requests", "1.0.0"); + + /// + /// Starts a span for a chat completion request. + /// + public static Activity? StartChatCompletionActivity(string model, bool isStreaming) + { + return ActivitySource.StartActivity("gateway.chat.completion", ActivityKind.Server, + Activity.Current?.Context ?? default, new TagList + { + { "gateway.model", model }, + { "gateway.streaming", isStreaming }, + { "gateway.operation", "chat_completion" } + }); + } + + /// + /// Starts a span for an image generation request. + /// + public static Activity? StartImageGenerationActivity(string model, bool isAsync) + { + return ActivitySource.StartActivity("gateway.image.generation", ActivityKind.Server, + Activity.Current?.Context ?? default, new TagList + { + { "gateway.model", model }, + { "gateway.async", isAsync }, + { "gateway.operation", "image_generation" } + }); + } + + /// + /// Starts a span for a video generation request. + /// + public static Activity? StartVideoGenerationActivity(string model) + { + return ActivitySource.StartActivity("gateway.video.generation", ActivityKind.Server, + Activity.Current?.Context ?? default, new TagList + { + { "gateway.model", model }, + { "gateway.operation", "video_generation" } + }); + } + + /// + /// Starts a span for an embeddings request. + /// + public static Activity? StartEmbeddingsActivity(string model) + { + return ActivitySource.StartActivity("gateway.embeddings", ActivityKind.Server, + Activity.Current?.Context ?? default, new TagList + { + { "gateway.model", model }, + { "gateway.operation", "embeddings" } + }); + } + + /// + /// Starts a span for virtual key authentication. + /// + public static Activity? StartAuthenticationActivity(string authType) + { + return ActivitySource.StartActivity("gateway.auth.validate", ActivityKind.Internal, + Activity.Current?.Context ?? default, new TagList + { + { "gateway.auth_type", authType }, + { "gateway.operation", "authentication" } + }); + } + + /// + /// Starts a span for usage tracking / billing. + /// + public static Activity? StartUsageTrackingActivity(string endpointType) + { + return ActivitySource.StartActivity("gateway.usage.tracking", ActivityKind.Internal, + Activity.Current?.Context ?? default, new TagList + { + { "gateway.endpoint_type", endpointType }, + { "gateway.operation", "usage_tracking" } + }); + } +} diff --git a/Services/ConduitLLM.Gateway/Metrics/PromptCachingMetrics.cs b/Services/ConduitLLM.Gateway/Metrics/PromptCachingMetrics.cs new file mode 100644 index 000000000..a38309b5d --- /dev/null +++ b/Services/ConduitLLM.Gateway/Metrics/PromptCachingMetrics.cs @@ -0,0 +1,64 @@ +using Prometheus; + +namespace ConduitLLM.Gateway.Metrics +{ + /// + /// Prometheus metrics for prompt caching observability. + /// Tracks cache hit/miss at the request level and estimated cost savings from cached token usage. + /// Injection metrics are in . + /// + public static class PromptCachingMetrics + { + /// + /// Total requests by cache status (hit = cached tokens returned, miss = no cached tokens, disabled = caching not active). + /// + public static readonly Counter RequestsTotal = Prometheus.Metrics + .CreateCounter("conduit_prompt_caching_requests_total", "Total requests by prompt caching status", + new CounterConfiguration + { + LabelNames = new[] { "model", "provider", "cache_status" } // cache_status: hit, miss, disabled + }); + + /// + /// Estimated cost savings in dollars from cached token usage. + /// Calculated as: cached_input_tokens * (standard_input_rate - cached_input_rate) per million tokens. + /// + public static readonly Counter SavingsDollarsTotal = Prometheus.Metrics + .CreateCounter("conduit_prompt_caching_savings_dollars", "Estimated cost savings from prompt caching in dollars", + new CounterConfiguration + { + LabelNames = new[] { "model", "provider" } + }); + + // Convenience methods + + /// + /// Record a request where cached tokens were returned by the provider. + /// + public static void RecordCacheHit(string model, string provider) + => RequestsTotal.WithLabels(model, provider, "hit").Inc(); + + /// + /// Record a request where no cached tokens were returned. + /// + public static void RecordCacheMiss(string model, string provider) + => RequestsTotal.WithLabels(model, provider, "miss").Inc(); + + /// + /// Record a request where prompt caching was not active (disabled or not configured). + /// + public static void RecordCacheDisabled(string model, string provider) + => RequestsTotal.WithLabels(model, provider, "disabled").Inc(); + + /// + /// Record estimated cost savings from cached token usage. + /// + public static void RecordSavings(string model, string provider, double savingsDollars) + { + if (savingsDollars > 0) + { + SavingsDollarsTotal.WithLabels(model, provider).Inc(savingsDollars); + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Middleware/EphemeralKeyCleanupMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/EphemeralKeyCleanupMiddleware.cs index f676950cc..a4c29705e 100644 --- a/Services/ConduitLLM.Gateway/Middleware/EphemeralKeyCleanupMiddleware.cs +++ b/Services/ConduitLLM.Gateway/Middleware/EphemeralKeyCleanupMiddleware.cs @@ -1,52 +1,24 @@ +using ConduitLLM.Core.Middleware; using ConduitLLM.Gateway.Services; namespace ConduitLLM.Gateway.Middleware { /// - /// Middleware that cleans up ephemeral keys after request completion + /// Middleware that cleans up ephemeral keys after request completion. /// - public class EphemeralKeyCleanupMiddleware + public class EphemeralKeyCleanupMiddleware : EphemeralKeyCleanupMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; + protected override string DeleteFlagKey => "DeleteEphemeralKey"; + protected override string KeyStorageKey => "EphemeralKey"; public EphemeralKeyCleanupMiddleware( RequestDelegate next, ILogger logger) + : base(next, logger) { - _next = next ?? throw new ArgumentNullException(nameof(next)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task InvokeAsync(HttpContext context, IEphemeralKeyService ephemeralKeyService) - { - try - { - // Process the request - await _next(context); - } - finally - { - // After request completes (success or failure), clean up ephemeral key if needed - if (context.Items.TryGetValue("DeleteEphemeralKey", out var shouldDelete) && - shouldDelete is bool delete && delete) - { - if (context.Items.TryGetValue("EphemeralKey", out var keyObj) && - keyObj is string ephemeralKey) - { - try - { - await ephemeralKeyService.DeleteKeyAsync(ephemeralKey); - _logger.LogDebug("Deleted ephemeral key after request completion"); - } - catch (Exception ex) - { - // Log but don't throw - cleanup is best effort - _logger.LogWarning(ex, "Failed to delete ephemeral key after request"); - } - } - } - } - } + public Task InvokeAsync(HttpContext context, IEphemeralKeyService ephemeralKeyService) + => InvokeAsync(context, ephemeralKeyService.DeleteKeyAsync); } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Middleware/GatewayRequestTrackingMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/GatewayRequestTrackingMiddleware.cs new file mode 100644 index 000000000..69dca0336 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Middleware/GatewayRequestTrackingMiddleware.cs @@ -0,0 +1,44 @@ +using ConduitLLM.Core.Middleware; + +namespace ConduitLLM.Gateway.Middleware +{ + /// + /// Middleware for tracking Gateway API requests with structured logging. + /// Logs mutations at Information level, slow reads at Information level, + /// and normal reads at Debug level for operational visibility. + /// + public class GatewayRequestTrackingMiddleware : RequestTrackingMiddlewareBase + { + public GatewayRequestTrackingMiddleware( + RequestDelegate next, + ILogger logger) + : base(next, logger) { } + + protected override string ServiceName => "Gateway API"; + + protected override int SlowRequestWarningThresholdMs => 5000; + + protected override bool ShouldSkipRequest(HttpContext context) + { + return context.Request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase); + } + + protected override string? GetRequestIdentifier(HttpContext context) + { + if (context.Items.TryGetValue("VirtualKeyId", out var keyId) && keyId is int id) + { + return id.ToString(); + } + + return context.User?.FindFirst("VirtualKeyId")?.Value ?? "anonymous"; + } + } + + public static class GatewayRequestTrackingMiddlewareExtensions + { + public static IApplicationBuilder UseGatewayRequestTracking(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Middleware/HttpMetricsMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/HttpMetricsMiddleware.cs index 85bc20d38..b68d247da 100644 --- a/Services/ConduitLLM.Gateway/Middleware/HttpMetricsMiddleware.cs +++ b/Services/ConduitLLM.Gateway/Middleware/HttpMetricsMiddleware.cs @@ -1,18 +1,15 @@ -using System.Diagnostics; - +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Middleware; using Prometheus; namespace ConduitLLM.Gateway.Middleware { /// /// Middleware for tracking HTTP request metrics using Prometheus. - /// Provides comprehensive metrics for monitoring API performance at scale. + /// Provides comprehensive metrics for monitoring Gateway API performance at scale. /// - public class HttpMetricsMiddleware + public class HttpMetricsMiddleware : HttpMetricsMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - // Prometheus metrics private static readonly Counter RequestsTotal = Prometheus.Metrics .CreateCounter("conduit_http_requests_total", "Total number of HTTP requests", @@ -75,101 +72,25 @@ public class HttpMetricsMiddleware AgeBuckets = 5 }); + private static readonly Counter ErrorsTotal = Prometheus.Metrics + .CreateCounter("conduit_http_errors_total", "Total number of HTTP errors", + new CounterConfiguration + { + LabelNames = new[] { "method", "endpoint", "status_code", "error_type" } + }); + public HttpMetricsMiddleware(RequestDelegate next, ILogger logger) - { - _next = next; - _logger = logger; - } + : base(next, logger) { } - public async Task InvokeAsync(HttpContext context) + protected override bool ShouldSkipMetrics(HttpContext context) { - var path = GetNormalizedPath(context.Request.Path); - var method = context.Request.Method; - - // Skip metrics for health checks to avoid noise - if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) - { - await _next(context); - return; - } - - // Track request size - if (context.Request.ContentLength.HasValue) - { - RequestSize.WithLabels(method, path).Observe(context.Request.ContentLength.Value); - } - - // Start timing the request - var stopwatch = Stopwatch.StartNew(); - - // Track active requests - using (ActiveRequests.WithLabels(method, path).TrackInProgress()) - { - try - { - // Capture original response body stream - var originalBodyStream = context.Response.Body; - using var responseBody = new System.IO.MemoryStream(); - context.Response.Body = responseBody; - - await _next(context); - - // Copy response to original stream and track size - context.Response.Body.Seek(0, System.IO.SeekOrigin.Begin); - await responseBody.CopyToAsync(originalBodyStream); - context.Response.Body = originalBodyStream; - - // Track response size - ResponseSize.WithLabels(method, path, context.Response.StatusCode.ToString()) - .Observe(responseBody.Length); - } - catch (OperationCanceledException) - { - context.Response.StatusCode = 499; // Client closed request - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unhandled exception in request pipeline"); - if (context.Response.StatusCode == 200) - { - context.Response.StatusCode = 500; - } - throw; - } - finally - { - stopwatch.Stop(); - var duration = stopwatch.Elapsed.TotalSeconds; - var statusCode = context.Response.StatusCode.ToString(); - var virtualKeyId = GetVirtualKeyId(context); - - // Record metrics - RequestsTotal.WithLabels(method, path, statusCode, virtualKeyId).Inc(); - RequestDuration.WithLabels(method, path, statusCode).Observe(duration); - RequestDurationSummary.WithLabels(method, path).Observe(duration); - - // Track rate limit hits - if (context.Response.StatusCode == 429) - { - RateLimitHits.WithLabels(path, virtualKeyId).Inc(); - } - - // Log slow requests - if (duration > 5.0) - { - _logger.LogWarning("Slow request detected: {Method} {Path} took {Duration:F2}s with status {StatusCode}", - method, path, duration, statusCode); - } - } - } + return context.Request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase); } - private static string GetNormalizedPath(PathString path) + protected override string GetNormalizedPath(HttpContext context) { - var pathValue = path.Value ?? "/"; + var pathValue = context.Request.Path.Value ?? "/"; - // Normalize common path patterns to reduce cardinality // Replace GUIDs with {id} pathValue = System.Text.RegularExpressions.Regex.Replace( pathValue, @@ -199,18 +120,54 @@ private static string GetNormalizedPath(PathString path) return pathValue.ToLowerInvariant(); } + protected override void IncrementActiveRequests(string method, string path) + => ActiveRequests.WithLabels(method, path).Inc(); + + protected override void DecrementActiveRequests(string method, string path) + => ActiveRequests.WithLabels(method, path).Dec(); + + protected override void RecordRequestSize(string method, string path, long bytes) + => RequestSize.WithLabels(method, path).Observe(bytes); + + protected override void RecordError(string method, string path, int statusCode, string errorType) + => ErrorsTotal.WithLabels(method, path, statusCode.ToString(), errorType).Inc(); + + protected override void OnException(HttpContext context) + { + if (context.Response.StatusCode == 200) + { + context.Response.StatusCode = 500; + } + } + + protected override void RecordResponseMetrics( + string method, string path, int statusCode, double durationSeconds, + long responseBytes, HttpContext context) + { + var statusCodeStr = statusCode.ToString(); + var virtualKeyId = GetVirtualKeyId(context); + + ResponseSize.WithLabels(method, path, statusCodeStr).Observe(responseBytes); + RequestsTotal.WithLabels(method, path, statusCodeStr, virtualKeyId).Inc(); + RequestDuration.WithLabels(method, path, statusCodeStr).Observe(durationSeconds); + RequestDurationSummary.WithLabels(method, path).Observe(durationSeconds); + + if (statusCode == 429) + { + RateLimitHits.WithLabels(path, virtualKeyId).Inc(); + } + } + private static string GetVirtualKeyId(HttpContext context) { - // Try to get virtual key ID from the authenticated user var virtualKeyId = context.User?.FindFirst("VirtualKeyId")?.Value; if (!string.IsNullOrEmpty(virtualKeyId)) return virtualKeyId; - // Try to get it from a custom header set by authentication if (context.Items.TryGetValue("VirtualKeyId", out var keyId) && keyId is string strKeyId) return strKeyId; return "anonymous"; } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Middleware/PrometheusMetricsMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/PrometheusMetricsMiddleware.cs deleted file mode 100644 index bd89bddef..000000000 --- a/Services/ConduitLLM.Gateway/Middleware/PrometheusMetricsMiddleware.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace ConduitLLM.Gateway.Middleware -{ - /// - /// Middleware for exposing Prometheus metrics endpoint. - /// - public class PrometheusMetricsMiddleware - { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly string _metricsPath; - - /// - /// Initializes a new instance of the class. - /// - /// The next middleware in the pipeline. - /// The logger. - /// The path for metrics endpoint. - public PrometheusMetricsMiddleware( - RequestDelegate next, - ILogger logger, - string metricsPath = "/metrics") - { - _next = next ?? throw new ArgumentNullException(nameof(next)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _metricsPath = metricsPath; - } - - /// - /// Invokes the middleware. - /// - /// The HTTP context. - /// A task representing the asynchronous operation. - public async Task InvokeAsync(HttpContext context) - { - if (context.Request.Path.Equals(_metricsPath, StringComparison.OrdinalIgnoreCase)) - { - await HandleMetricsRequest(context); - return; - } - - await _next(context); - } - - private async Task HandleMetricsRequest(HttpContext context) - { - try - { - // PrometheusAudioMetricsExporter removed - metrics are now exposed differently - // For now, return a placeholder response - context.Response.StatusCode = StatusCodes.Status200OK; - context.Response.ContentType = "text/plain; version=0.0.4; charset=utf-8"; - context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; - await context.Response.WriteAsync("# Metrics endpoint temporarily disabled during refactoring\n"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error serving Prometheus metrics"); - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsync("Error generating metrics"); - } - } - } - - /// - /// Extension methods for adding Prometheus metrics middleware. - /// - public static class PrometheusMetricsMiddlewareExtensions - { - /// - /// Adds Prometheus metrics endpoint to the application pipeline. - /// - /// The application builder. - /// The path for the metrics endpoint. - /// The application builder. - public static IApplicationBuilder UsePrometheusMetrics( - this IApplicationBuilder builder, - string path = "/metrics") - { - return builder.UseMiddleware(path); - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Middleware/SecurityMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/SecurityMiddleware.cs index ae0304db1..0932e6a49 100644 --- a/Services/ConduitLLM.Gateway/Middleware/SecurityMiddleware.cs +++ b/Services/ConduitLLM.Gateway/Middleware/SecurityMiddleware.cs @@ -1,24 +1,24 @@ -using ConduitLLM.Core.Utilities; -using ConduitLLM.Gateway.Services; using ConduitLLM.Security.Interfaces; +using ConduitLLM.Security.Middleware; +using ConduitLLM.Security.Models; +using ISecurityService = ConduitLLM.Security.Interfaces.ISecurityService; namespace ConduitLLM.Gateway.Middleware { /// - /// Unified security middleware for Gateway API that handles IP filtering, rate limiting, and ban checks + /// Unified security middleware for Gateway API that handles IP filtering, rate limiting, and ban checks. + /// Inherits from SecurityMiddlewareBase and adds event monitoring functionality. /// - public class SecurityMiddleware + public class SecurityMiddleware : SecurityMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; + private ISecurityEventMonitoringService? _securityEventMonitoring; /// /// Initializes a new instance of the SecurityMiddleware /// public SecurityMiddleware(RequestDelegate next, ILogger logger) + : base(next, logger) { - _next = next; - _logger = logger; } /// @@ -26,65 +26,62 @@ public SecurityMiddleware(RequestDelegate next, ILogger logg /// public async Task InvokeAsync(HttpContext context, ISecurityService securityService, ISecurityEventMonitoringService? securityEventMonitoring = null) { - var clientIp = IpAddressHelper.GetClientIpAddress(context); - var endpoint = context.Request.Path.Value ?? ""; - - // Pass along any authentication failure info from VirtualKeyAuthenticationMiddleware - if (context.Response.StatusCode == 401) - { - // Authentication already failed, don't continue - return; - } + _securityEventMonitoring = securityEventMonitoring; + await ProcessRequestAsync(context, ctx => securityService.IsRequestAllowedAsync(ctx)); + } - var result = await securityService.IsRequestAllowedAsync(context); + /// + /// Logs granular security events and records them via the monitoring service. + /// + protected override Task OnSecurityViolationAsync(HttpContext context, SecurityCheckResult result, string clientIp) + { + var method = context.Request.Method; + var path = context.Request.Path.Value ?? ""; + var virtualKey = context.Items["AttemptedKey"] as string ?? ""; - if (!result.IsAllowed) + switch (result.StatusCode) { - _logger.LogWarning("Request blocked: {Reason} for path {Path} from IP {IP}", - result.Reason, - context.Request.Path, - clientIp); - - // Record security events based on the reason - if (securityEventMonitoring != null) - { - var virtualKey = context.Items["AttemptedKey"] as string ?? ""; - - if (result.Reason.Contains("rate limit", StringComparison.OrdinalIgnoreCase)) - { - var limitType = result.Headers.ContainsKey("X-RateLimit-Scope") - ? result.Headers["X-RateLimit-Scope"] - : "general"; - securityEventMonitoring.RecordRateLimitViolation(clientIp, virtualKey, endpoint, limitType); - } - else if (result.Reason.Contains("banned", StringComparison.OrdinalIgnoreCase)) - { - // IP ban is already recorded by SecurityService - } - else - { - securityEventMonitoring.RecordSuspiciousActivity(clientIp, "Access Denied", result.Reason); - } - } + case 401: + Logger.LogWarning( + "Security event: AuthenticationFailure โ€” {Method} {Path} from {ClientIp}. Reason: {Reason}", + method, path, clientIp, result.Reason); + break; + case 429: + Logger.LogWarning( + "Security event: RateLimitExceeded โ€” {Method} {Path} from {ClientIp} [VirtualKey: {VirtualKey}]. Reason: {Reason}", + method, path, clientIp, virtualKey, result.Reason); + break; + case 403: + Logger.LogWarning( + "Security event: AccessDenied โ€” {Method} {Path} from {ClientIp}. Reason: {Reason}", + method, path, clientIp, result.Reason); + break; + default: + Logger.LogWarning( + "Security event: Blocked ({StatusCode}) โ€” {Method} {Path} from {ClientIp}. Reason: {Reason}", + result.StatusCode, method, path, clientIp, result.Reason); + break; + } - context.Response.StatusCode = result.StatusCode ?? 403; + // Record to monitoring service if available + if (_securityEventMonitoring == null) + return Task.CompletedTask; - // Add any response headers - foreach (var header in result.Headers) - { - context.Response.Headers.Append(header.Key, header.Value); - } + var endpoint = path; - // Return JSON error response - await context.Response.WriteAsJsonAsync(new - { - error = result.Reason, - code = result.StatusCode - }); - return; + if (result.Reason.Contains("rate limit", StringComparison.OrdinalIgnoreCase)) + { + var limitType = result.Headers.ContainsKey("X-RateLimit-Scope") + ? result.Headers["X-RateLimit-Scope"] + : "general"; + _securityEventMonitoring.RecordRateLimitViolation(clientIp, virtualKey, endpoint, limitType); + } + else if (!result.Reason.Contains("banned", StringComparison.OrdinalIgnoreCase)) + { + _securityEventMonitoring.RecordSuspiciousActivity(clientIp, "Access Denied", result.Reason); } - await _next(context); + return Task.CompletedTask; } } @@ -101,4 +98,4 @@ public static IApplicationBuilder UseCoreApiSecurity(this IApplicationBuilder bu return builder.UseMiddleware(); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Middleware/SpendUpdateHelper.cs b/Services/ConduitLLM.Gateway/Middleware/SpendUpdateHelper.cs index aed3cb737..a95f060ed 100644 --- a/Services/ConduitLLM.Gateway/Middleware/SpendUpdateHelper.cs +++ b/Services/ConduitLLM.Gateway/Middleware/SpendUpdateHelper.cs @@ -1,16 +1,20 @@ using ConduitLLM.Core.Interfaces; using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Gateway.Metrics; using IVirtualKeyService = ConduitLLM.Core.Interfaces.IVirtualKeyService; namespace ConduitLLM.Gateway.Middleware { /// - /// Helper methods for updating virtual key spending. + /// Helper methods for updating virtual key spending with 3-tier fallback: + /// 1. Redis batch queue (primary, high throughput) + /// 2. Direct database write (fallback when Redis is down) + /// 3. In-memory fallback queue (last resort, drained on next flush cycle) /// public static class SpendUpdateHelper { /// - /// Updates virtual key spending through batch service or direct update. + /// Updates virtual key spending with cascading fallback to prevent data loss. /// public static async Task UpdateSpendAsync( int virtualKeyId, @@ -19,25 +23,44 @@ public static async Task UpdateSpendAsync( IVirtualKeyService virtualKeyService, ILogger logger) { - try + // Tier 1: Try Redis batch queue (primary path) + if (batchSpendService.IsHealthy) { - // Try batch update first - if (batchSpendService.IsHealthy) + try { - batchSpendService.QueueSpendUpdate(virtualKeyId, cost); + await batchSpendService.QueueSpendUpdateAsync(virtualKeyId, cost); + return; } - else + catch (Exception ex) { - // Fallback to direct update - logger.LogWarning("BatchSpendUpdateService unhealthy, using direct update for VirtualKey {VirtualKeyId}", virtualKeyId); - await virtualKeyService.UpdateSpendAsync(virtualKeyId, cost); + logger.LogWarning(ex, + "Redis batch queue failed for VirtualKey {VirtualKeyId} ({Cost:C}), falling back to direct DB update", + virtualKeyId, cost); + BillingMetrics.RecordSpendUpdateFailure(virtualKeyId, "redis_queue_failed"); } } + else + { + logger.LogWarning("BatchSpendUpdateService unhealthy, using direct update for VirtualKey {VirtualKeyId}", virtualKeyId); + } + + // Tier 2: Try direct database write + try + { + await virtualKeyService.UpdateSpendAsync(virtualKeyId, cost); + return; + } catch (Exception ex) { - logger.LogError(ex, "Failed to update spend for VirtualKey {VirtualKeyId}, Cost {Cost:C}", virtualKeyId, cost); - // Don't throw - we've already sent the response to the user + logger.LogError(ex, + "Direct DB spend update also failed for VirtualKey {VirtualKeyId} ({Cost:C}), queuing to in-memory fallback", + virtualKeyId, cost); + BillingMetrics.RecordSpendUpdateFailure(virtualKeyId, "direct_db_failed"); } + + // Tier 3: In-memory fallback queue (drained on next successful flush cycle) + batchSpendService.QueueFallbackUpdate(virtualKeyId, cost); + BillingMetrics.RecordPotentialRevenueLoss(cost, "fallback_queue"); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Middleware/UsageExtractor.cs b/Services/ConduitLLM.Gateway/Middleware/UsageExtractor.cs index 93dce30af..0b02141f9 100644 --- a/Services/ConduitLLM.Gateway/Middleware/UsageExtractor.cs +++ b/Services/ConduitLLM.Gateway/Middleware/UsageExtractor.cs @@ -298,15 +298,30 @@ public static double GetResponseTime(HttpContext context) { ToolName = billingToolName, Count = count, - // For code_interpreter, we might want to track duration - // For now, we'll use a standard unit (could be enhanced later) - Duration = billingToolName == "code_interpreter" ? 1 : null + Duration = null // Duration populated below from provider-specific fields }); } } } - if (toolUsageList.Any()) + // Extract duration data from Groq-specific fields (e.g., code_interpreter execution time) + // Groq may report duration in seconds as a separate field like "code_interpreter_duration_seconds" + foreach (var toolItem in toolUsageList) + { + // Try provider-specific duration fields (seconds) + var durationKey = $"{toolItem.ToolName}_duration_seconds"; + if (usage.TryGetProperty(durationKey, out var durationSeconds) && + durationSeconds.ValueKind == JsonValueKind.Number) + { + var seconds = durationSeconds.GetDecimal(); + // Convert seconds to hours for "hours" billing, or minutes for "minutes" billing + // Store as raw seconds and let CalculateUsageAmount handle unit conversion + toolItem.DurationSeconds = seconds; + logger.LogDebug("Tool {ToolName} duration: {DurationSeconds}s", toolItem.ToolName, seconds); + } + } + + if (toolUsageList.Count > 0) { return new ToolUsageData { Tools = toolUsageList }; } @@ -348,9 +363,16 @@ public class ToolUsageItem public int Count { get; set; } /// - /// Duration of tool usage (for time-based billing like code execution) + /// Duration of tool usage in the billing unit (hours/minutes). + /// When set explicitly, takes priority over DurationSeconds. /// public decimal? Duration { get; set; } + + /// + /// Raw duration in seconds as reported by the provider. + /// Used by CalculateUsageAmount to convert to the appropriate billing unit. + /// + public decimal? DurationSeconds { get; set; } } /// diff --git a/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.BillingAndMetrics.cs b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.BillingAndMetrics.cs new file mode 100644 index 000000000..79a2c3d51 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.BillingAndMetrics.cs @@ -0,0 +1,176 @@ +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Gateway.Constants; +using ConduitLLM.Gateway.Metrics; +using ConduitLLM.Gateway.Utilities; + +namespace ConduitLLM.Gateway.Middleware +{ + public partial class UsageTrackingMiddleware + { + private async Task LogRequestAsync( + HttpContext context, + int virtualKeyId, + string model, + Usage usage, + decimal cost, + IRequestLogService requestLogService, + string? metadata = null) + { + try + { + var requestType = UsageExtractor.DetermineRequestType(context.Request.Path); + + // Extract provider info from HttpContext.Items (set by controllers) + int? providerId = context.Items.TryGetValue("ProviderId", out var providerIdObj) && providerIdObj is int pid + ? pid + : null; + var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) + ? providerTypeObj?.ToString() + : null; + + var logRequest = new LogRequestDto + { + VirtualKeyId = virtualKeyId, + ModelName = model, + ProviderId = providerId, + ProviderType = providerType, + RequestType = requestType, + InputTokens = usage.PromptTokens ?? 0, + OutputTokens = usage.CompletionTokens ?? 0, + CachedInputTokens = usage.CachedInputTokens, + CachedWriteTokens = usage.CachedWriteTokens, + Cost = cost, + ResponseTimeMs = UsageExtractor.GetResponseTime(context), + UserId = context.User?.Identity?.Name, + ClientIp = context.Connection.RemoteIpAddress?.ToString(), + RequestPath = context.Request.Path.ToString(), + StatusCode = context.Response.StatusCode, + Metadata = metadata + }; + + await requestLogService.LogRequestAsync(logRequest); + + _logger.LogInformation( + "Tracked usage for VirtualKey {VirtualKeyId}: Model={Model}, PromptTokens={PromptTokens}, CompletionTokens={CompletionTokens}, CachedInput={CachedInput}, CachedWrite={CachedWrite}, Cost={Cost:C}", + virtualKeyId, model, usage.PromptTokens, usage.CompletionTokens, usage.CachedInputTokens, usage.CachedWriteTokens, cost); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to log request for VirtualKey {VirtualKeyId}", virtualKeyId); + // Don't throw - logging failure shouldn't break the request + } + } + + #region Billing Audit Logging + + private async Task LogBillingDecisionAsync(HttpContext context, IBillingAuditService billingAuditService) + { + await BillingPolicyHandler.LogBillingDecisionAsync(context, billingAuditService, _logger); + } + + private void LogSuccessfulBilling(HttpContext context, string model, Usage usage, decimal cost, + string providerType, IBillingAuditService billingAuditService, string? toolUsageJson = null, decimal? toolCost = null) + { + BillingPolicyHandler.LogSuccessfulBilling(context, model, usage, cost, providerType, billingAuditService, _logger, toolUsageJson, toolCost); + } + + private void LogZeroCostBilling(HttpContext context, string model, Usage usage, decimal cost, + string providerType, IBillingAuditService billingAuditService, string? toolUsageJson = null, decimal? toolCost = null) + { + BillingPolicyHandler.LogZeroCostBilling(context, model, usage, cost, providerType, billingAuditService, toolUsageJson, toolCost, _logger); + } + + private void LogMissingUsageData(HttpContext context, IBillingAuditService billingAuditService) + { + BillingPolicyHandler.LogMissingUsageData(context, billingAuditService); + } + + private void LogStreamingBilling(HttpContext context, string model, Usage usage, decimal cost, + string providerType, bool isEstimated, IBillingAuditService billingAuditService, string? toolUsageJson = null, decimal? toolCost = null) + { + BillingPolicyHandler.LogStreamingBilling(context, model, usage, cost, providerType, isEstimated, billingAuditService, _logger, toolUsageJson, toolCost); + } + + private void LogMissingStreamingUsage(HttpContext context, IBillingAuditService billingAuditService) + { + BillingPolicyHandler.LogMissingStreamingUsage(context, billingAuditService); + } + + private void LogJsonParseError(HttpContext context, Exception ex, IBillingAuditService billingAuditService) + { + BillingPolicyHandler.LogJsonParseError(context, ex, billingAuditService); + } + + private void LogUnexpectedError(HttpContext context, Exception ex, IBillingAuditService billingAuditService) + { + BillingPolicyHandler.LogUnexpectedError(context, ex, billingAuditService); + } + + #endregion + + #region Prompt Caching Metrics + + /// + /// Records prompt caching request-level metrics (hit/miss/disabled). + /// + private static void RecordPromptCachingMetrics(Usage usage, string model, string provider) + { + if (usage.CachedInputTokens.HasValue && usage.CachedInputTokens.Value > 0) + { + PromptCachingMetrics.RecordCacheHit(model, provider); + } + else if (usage.CachedWriteTokens.HasValue && usage.CachedWriteTokens.Value > 0) + { + // Cache write but no read โ€” first request building the cache + PromptCachingMetrics.RecordCacheMiss(model, provider); + } + else + { + PromptCachingMetrics.RecordCacheDisabled(model, provider); + } + } + + /// + /// Calculates and records prompt caching cost savings. + /// + private static async Task RecordPromptCachingSavingsAsync( + HttpContext context, + ICostCalculationService costCalculationService, + string model, + Usage usage) + { + if (!usage.CachedInputTokens.HasValue || usage.CachedInputTokens.Value <= 0) + return; + + try + { + decimal savings; + var providerType = context.Items.TryGetValue("ProviderType", out var pt) + ? pt?.ToString() ?? "unknown" + : "unknown"; + + if (context.Items.TryGetValue(HttpContextKeys.ModelCostId, out var mcIdObj) && + mcIdObj is int mcId) + { + savings = await costCalculationService.CalculateCacheSavingsByIdAsync(mcId, usage); + } + else + { + savings = await costCalculationService.CalculateCacheSavingsAsync(model, usage); + } + + PromptCachingMetrics.RecordSavings(model, providerType, Convert.ToDouble(savings)); + } + catch + { + // Non-critical โ€” don't fail the request pipeline for savings calculation + } + } + + #endregion + } +} diff --git a/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.MediaProcessing.cs b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.MediaProcessing.cs new file mode 100644 index 000000000..8a530503d --- /dev/null +++ b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.MediaProcessing.cs @@ -0,0 +1,423 @@ +using System.Text.Json; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Gateway.Constants; +using ConduitLLM.Gateway.Metrics; +using ConduitLLM.Gateway.Services; +using ConduitLLM.Gateway.UsageTracking; +using ConduitLLM.Gateway.Utilities; +using IVirtualKeyService = ConduitLLM.Core.Interfaces.IVirtualKeyService; + +namespace ConduitLLM.Gateway.Middleware +{ + public partial class UsageTrackingMiddleware + { + /// + /// Holds the type-specific data extracted by image/video adapters + /// so the shared pipeline can process any media type uniformly. + /// + private sealed class MediaProcessingContext + { + public required string MediaType { get; init; } + public required string Model { get; init; } + public required Usage Usage { get; init; } + public required string MetadataJson { get; init; } + public required string ProviderType { get; init; } + public required int VirtualKeyId { get; init; } + public required string LogDetail { get; init; } + } + + /// + /// Resolves the model name: prefer the value stored in HttpContext.Items by the controller, + /// fall back to the model returned in the provider response, then "unknown". + /// + /// + /// Resolves the canonical model name. Prefers the model recorded in the request + /// usage context (set by the controller before provider mapping); falls back to + /// the model echoed in the response, then to "unknown". + /// + private static string ResolveModelFromUsage(string? requestModel, string? responseModel) + { + return string.IsNullOrEmpty(requestModel) ? (responseModel ?? "unknown") : requestModel; + } + + /// + /// Shared pipeline for image and video response processing. + /// Handles cost calculation, metrics, spend updates, billing audit, and request logging. + /// + private async Task ProcessMediaResponseAsync( + HttpContext context, + MediaProcessingContext media, + ICostCalculationService costCalculationService, + IBatchSpendUpdateService batchSpendService, + IRequestLogService requestLogService, + IVirtualKeyService virtualKeyService, + IBillingAuditService billingAuditService) + { + // Calculate cost - prefer ID-based lookup if ModelCostId is available + decimal cost; + if (context.Items.TryGetValue(HttpContextKeys.ModelCostId, out var modelCostIdObj) && + modelCostIdObj is int modelCostId) + { + cost = await costCalculationService.CalculateCostByIdAsync(modelCostId, media.Usage); + } + else + { + cost = await costCalculationService.CalculateCostAsync(media.Model, media.Usage); + } + + // Update Prometheus metrics + UsageMetrics.UsageTrackingRequests.WithLabels(media.MediaType, "success").Inc(); + UsageMetrics.UsageTrackingCosts.WithLabels(media.Model, media.ProviderType, media.MediaType) + .Inc(Convert.ToDouble(cost)); + + // Record business metrics for Grafana dashboards + var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 + ? "success" : "error"; + BusinessMetricsService.RecordModelRequest(media.Model, media.ProviderType, requestStatus); + BusinessMetricsService.RecordResponseTime(media.Model, media.ProviderType, + UsageExtractor.GetResponseTime(context) / 1000.0); + if (cost > 0) + { + BusinessMetricsService.RecordCost(media.ProviderType, media.Model, media.MediaType, + Convert.ToDouble(cost)); + } + + // Update spend and log billing + if (cost > 0) + { + await SpendUpdateHelper.UpdateSpendAsync(media.VirtualKeyId, cost, + batchSpendService, virtualKeyService, _logger); + LogSuccessfulBilling(context, media.Model, media.Usage, cost, + media.ProviderType, billingAuditService); + } + else + { + UsageMetrics.ZeroCostEvents.WithLabels(media.Model, $"{media.MediaType}_zero").Inc(); + LogZeroCostBilling(context, media.Model, media.Usage, cost, + media.ProviderType, billingAuditService); + } + + // Log the request with media metadata + await LogRequestAsync(context, media.VirtualKeyId, media.Model, media.Usage, cost, + requestLogService, media.MetadataJson); + + _logger.LogInformation( + "Tracked {MediaType} generation for VirtualKey {VirtualKeyId}: Model={Model}, {Detail}, Cost={Cost:C}", + media.MediaType, media.VirtualKeyId, media.Model, media.LogDetail, cost); + } + + /// + /// Process function execution responses and log them with function-specific metadata. + /// + private async Task ProcessFunctionResponseAsync( + HttpContext context, + MemoryStream responseBody, + IBatchSpendUpdateService batchSpendService, + IRequestLogService requestLogService, + IVirtualKeyService virtualKeyService, + IBillingAuditService billingAuditService) + { + try + { + // Get virtual key ID + var virtualKeyId = (int)context.Items["VirtualKeyId"]!; + + // Get function configuration info from HttpContext.Items (set by FunctionsController) + var functionConfigId = context.Items.TryGetValue("FunctionConfigurationId", out var configIdObj) + ? configIdObj as int? ?? 0 + : 0; + var functionName = context.Items.TryGetValue("FunctionConfigurationName", out var nameObj) + ? nameObj?.ToString() ?? "unknown" + : "unknown"; + var executionId = context.Items.TryGetValue("FunctionExecutionId", out var execIdObj) + ? execIdObj as Guid? ?? Guid.Empty + : Guid.Empty; + + // Parse the response to get cost and state + using var jsonDocument = await JsonDocument.ParseAsync(responseBody); + var root = jsonDocument.RootElement; + + decimal cost = 0; + string state = "unknown"; + string? errorMessage = null; + + if (root.TryGetProperty("actualCost", out var actualCostElement)) + { + cost = actualCostElement.ValueKind == JsonValueKind.Number + ? actualCostElement.GetDecimal() + : 0; + } + else if (root.TryGetProperty("estimatedCost", out var estimatedCostElement)) + { + cost = estimatedCostElement.ValueKind == JsonValueKind.Number + ? estimatedCostElement.GetDecimal() + : 0; + } + + if (root.TryGetProperty("state", out var stateElement)) + { + state = stateElement.GetString() ?? "unknown"; + } + + if (root.TryGetProperty("errorMessage", out var errorElement) && errorElement.ValueKind == JsonValueKind.String) + { + errorMessage = errorElement.GetString(); + } + + // Build metadata JSON for function execution + var metadata = JsonSerializer.Serialize(new + { + type = "function", + functionConfigurationId = functionConfigId, + functionName, + executionId, + state, + errorMessage + }); + + // Get provider type for metrics + var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) + ? providerTypeObj?.ToString() ?? "unknown" + : "unknown"; + + // Update metrics + UsageMetrics.UsageTrackingRequests.WithLabels("function", "success").Inc(); + UsageMetrics.UsageTrackingCosts.WithLabels(functionName, providerType, "function").Inc(Convert.ToDouble(cost)); + + // Record business metrics for Grafana dashboards (real-time counters) + var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 ? "success" : "error"; + BusinessMetricsService.RecordModelRequest(functionName, providerType, requestStatus); + BusinessMetricsService.RecordResponseTime(functionName, providerType, UsageExtractor.GetResponseTime(context) / 1000.0); + if (cost > 0) + { + BusinessMetricsService.RecordCost(providerType, functionName, "function", Convert.ToDouble(cost)); + } + + // Update spend if there's a cost + if (cost > 0) + { + await SpendUpdateHelper.UpdateSpendAsync(virtualKeyId, cost, batchSpendService, virtualKeyService, _logger); + } + + // Create a Usage object with zero tokens (functions don't use tokens) + var usage = new Usage + { + PromptTokens = 0, + CompletionTokens = 0, + TotalTokens = 0 + }; + + // Log the request with function metadata + await LogRequestAsync(context, virtualKeyId, functionName, usage, cost, requestLogService, metadata); + + _logger.LogInformation( + "Tracked function execution for VirtualKey {VirtualKeyId}: Function={FunctionName}, ExecutionId={ExecutionId}, Cost={Cost:C}", + virtualKeyId, functionName, executionId, cost); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process function response for usage tracking"); + UsageMetrics.UsageTrackingFailures.WithLabels("function_processing_error", "function").Inc(); + } + } + + /// + /// Process image generation responses and log them with image-specific metadata. + /// Extracts image-specific data, then delegates to the shared media pipeline. + /// + private async Task ProcessImageResponseAsync( + HttpContext context, + MemoryStream responseBody, + ICostCalculationService costCalculationService, + IBatchSpendUpdateService batchSpendService, + IRequestLogService requestLogService, + IVirtualKeyService virtualKeyService, + IBillingAuditService billingAuditService) + { + try + { + var virtualKeyId = (int)context.Items["VirtualKeyId"]!; + + // Extract image request details from the typed usage context (set by ImagesController) + var imageUsage = context.GetUsageContext() as ImageUsageContext; + var quality = imageUsage?.Quality; + var size = imageUsage?.Size; + var requestedN = imageUsage?.N ?? 1; + var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) + ? providerTypeObj?.ToString() ?? "unknown" + : "unknown"; + + // Parse the response + int actualImageCount = requestedN; + Usage? responseUsage = null; + string? responseModel = null; + + using var jsonDocument = await JsonDocument.ParseAsync(responseBody); + var root = jsonDocument.RootElement; + + if (root.TryGetProperty("model", out var modelElement)) + responseModel = modelElement.GetString(); + + if (root.TryGetProperty("data", out var dataArray) && dataArray.ValueKind == JsonValueKind.Array) + actualImageCount = dataArray.GetArrayLength(); + + if (root.TryGetProperty("usage", out var usageElement)) + responseUsage = UsageExtractor.ExtractUsage(usageElement, _logger); + + var model = ResolveModelFromUsage(imageUsage?.Model, responseModel); + + // Build Usage object - prefer response usage if available, otherwise construct from request data + var usage = responseUsage ?? new Usage + { + ImageCount = actualImageCount, + ImageQuality = quality, + ImageResolution = size + }; + if (!usage.ImageCount.HasValue || usage.ImageCount.Value == 0) + usage.ImageCount = actualImageCount; + if (string.IsNullOrEmpty(usage.ImageQuality)) + usage.ImageQuality = quality; + if (string.IsNullOrEmpty(usage.ImageResolution)) + usage.ImageResolution = size; + + var metadata = JsonSerializer.Serialize(new + { + type = "image", + imageCount = actualImageCount, + quality = quality ?? "standard", + size = size ?? "unknown", + style = imageUsage?.Style + }); + + await ProcessMediaResponseAsync(context, new MediaProcessingContext + { + MediaType = "image", + Model = model, + Usage = usage, + MetadataJson = metadata, + ProviderType = providerType, + VirtualKeyId = virtualKeyId, + LogDetail = $"Images={actualImageCount}, Quality={quality ?? "standard"}, Size={size ?? "default"}" + }, costCalculationService, batchSpendService, requestLogService, virtualKeyService, billingAuditService); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process image response for usage tracking"); + UsageMetrics.UsageTrackingFailures.WithLabels("image_processing_error", "image").Inc(); + } + } + + /// + /// Process video generation responses and log them with video-specific metadata. + /// Extracts video-specific data, then delegates to the shared media pipeline. + /// + private async Task ProcessVideoResponseAsync( + HttpContext context, + MemoryStream responseBody, + ICostCalculationService costCalculationService, + IBatchSpendUpdateService batchSpendService, + IRequestLogService requestLogService, + IVirtualKeyService virtualKeyService, + IBillingAuditService billingAuditService) + { + try + { + var virtualKeyId = (int)context.Items["VirtualKeyId"]!; + + // Extract video request details from the typed usage context (set by VideosController) + var videoUsage = context.GetUsageContext() as VideoUsageContext; + var size = videoUsage?.Size; + var requestedDuration = videoUsage?.Duration; + var requestedN = videoUsage?.N ?? 1; + var fps = videoUsage?.Fps; + var style = videoUsage?.Style; + var pricingParameters = videoUsage?.PricingParameters; + var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) + ? providerTypeObj?.ToString() ?? "unknown" + : "unknown"; + + // Parse the response + int actualVideoCount = requestedN; + Usage? responseUsage = null; + string? responseModel = null; + double? actualDuration = null; + string? actualResolution = null; + string? taskId = null; + + responseBody.Seek(0, SeekOrigin.Begin); + using var jsonDocument = await JsonDocument.ParseAsync(responseBody); + var root = jsonDocument.RootElement; + + if (root.TryGetProperty("taskId", out var taskIdElement)) + taskId = taskIdElement.GetString(); + + if (root.TryGetProperty("model", out var modelElement)) + responseModel = modelElement.GetString(); + + if (root.TryGetProperty("data", out var dataArray) && dataArray.ValueKind == JsonValueKind.Array) + { + actualVideoCount = dataArray.GetArrayLength(); + + if (actualVideoCount > 0) + { + var firstVideo = dataArray[0]; + if (firstVideo.TryGetProperty("metadata", out var videoMetadata)) + { + if (videoMetadata.TryGetProperty("duration", out var durationEl)) + actualDuration = durationEl.GetDouble(); + if (videoMetadata.TryGetProperty("width", out var widthEl) && + videoMetadata.TryGetProperty("height", out var heightEl)) + actualResolution = $"{widthEl.GetInt32()}x{heightEl.GetInt32()}"; + } + } + } + + if (root.TryGetProperty("usage", out var usageElement)) + responseUsage = UsageExtractor.ExtractUsage(usageElement, _logger); + + var model = ResolveModelFromUsage(videoUsage?.Model, responseModel); + + // Build Usage object + var usage = responseUsage ?? new Usage(); + if (!usage.VideoDurationSeconds.HasValue) + usage.VideoDurationSeconds = actualDuration ?? requestedDuration; + if (string.IsNullOrEmpty(usage.VideoResolution)) + usage.VideoResolution = actualResolution ?? size; + if (pricingParameters != null && pricingParameters.Count > 0) + usage.PricingParameters = pricingParameters; + + var metadata = JsonSerializer.Serialize(new + { + type = "video", + taskId, + videoCount = actualVideoCount, + durationSeconds = usage.VideoDurationSeconds, + resolution = usage.VideoResolution ?? "unknown", + fps, + style, + pricingParametersUsed = pricingParameters?.Keys.ToArray() + }); + + await ProcessMediaResponseAsync(context, new MediaProcessingContext + { + MediaType = "video", + Model = model, + Usage = usage, + MetadataJson = metadata, + ProviderType = providerType, + VirtualKeyId = virtualKeyId, + LogDetail = $"Videos={actualVideoCount}, Duration={usage.VideoDurationSeconds ?? 0}s, Resolution={usage.VideoResolution ?? "unknown"}" + }, costCalculationService, batchSpendService, requestLogService, virtualKeyService, billingAuditService); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process video response for usage tracking"); + UsageMetrics.UsageTrackingFailures.WithLabels("video_processing_error", "video").Inc(); + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.Streaming.cs b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.Streaming.cs new file mode 100644 index 000000000..a5be0f758 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.Streaming.cs @@ -0,0 +1,213 @@ +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Gateway.Constants; +using ConduitLLM.Gateway.Controllers; +using ConduitLLM.Gateway.Metrics; +using ConduitLLM.Gateway.Services; +using ConduitLLM.Gateway.Utilities; +using IVirtualKeyService = ConduitLLM.Core.Interfaces.IVirtualKeyService; + +namespace ConduitLLM.Gateway.Middleware +{ + public partial class UsageTrackingMiddleware + { + private async Task TrackStreamingUsageAsync( + HttpContext context, + ICostCalculationService costCalculationService, + IBatchSpendUpdateService batchSpendService, + IRequestLogService requestLogService, + IVirtualKeyService virtualKeyService, + IBillingAuditService billingAuditService, + IToolCostCalculationService toolCostCalculationService) + { + var endpointType = UsageExtractor.DetermineRequestType(context.Request.Path); + + // Check if usage was estimated + var isEstimated = context.Items.TryGetValue("UsageIsEstimated", out var estimatedObj) && + estimatedObj is bool estimated && estimated; + + // For streaming responses, we need to rely on the SSE writer + // to have stored the usage data in HttpContext.Items + if (!context.Items.TryGetValue("StreamingUsage", out var usageObj) || + usageObj is not Usage usage) + { + _logger.LogDebug("No streaming usage data found for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); + UsageMetrics.UsageTrackingFailures.WithLabels("no_streaming_usage", endpointType).Inc(); + LogMissingStreamingUsage(context, billingAuditService); + return; + } + + if (!context.Items.TryGetValue("StreamingModel", out var modelObj) || + modelObj is not string model) + { + _logger.LogWarning("No streaming model found for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); + UsageMetrics.UsageTrackingFailures.WithLabels("no_streaming_model", endpointType).Inc(); + return; + } + + var virtualKeyId = (int)context.Items["VirtualKeyId"]!; + + // Get provider type for metrics + var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) + ? providerTypeObj?.ToString() ?? "unknown" + : "unknown"; + + // Parse provider type enum for tool usage + var providerTypeEnum = Enum.TryParse(providerType, true, out var parsedProviderType) + ? parsedProviderType + : ProviderType.OpenAI; + + // Extract tool usage from streaming context if available (provider-hosted tools) + var toolUsageData = context.Items.TryGetValue("StreamingToolUsage", out var toolObj) + ? toolObj as ToolUsageData + : null; + + decimal? toolCost = null; + string? toolUsageJson = null; + + if (toolUsageData != null) + { + var toolCostResult = await toolCostCalculationService.CalculateToolCostsAsync(toolUsageData, providerTypeEnum); + toolUsageJson = toolCostCalculationService.SerializeToolUsage(toolUsageData); + + if (!toolCostResult.Failed) + { + toolCost = toolCostResult.TotalCost; + _logger.LogDebug("Streaming tool usage detected: {ToolUsageJson}, Cost: ${ToolCost}", toolUsageJson, toolCost); + } + else + { + toolCost = 0m; + _logger.LogError("Streaming tool cost calculation failed for provider {ProviderType}.", providerTypeEnum); + } + + // Only emit when there's also billable cost โ€” BillingPolicyHandler handles the zero-cost case + if (toolCostResult.HasUnconfiguredTools && toolCost > 0) + { + billingAuditService.LogBillingEvent(new Configuration.Entities.BillingAuditEvent + { + EventType = Configuration.Entities.BillingAuditEventType.ToolUsageMissingCostConfig, + VirtualKeyId = virtualKeyId, + Model = model, + RequestId = context.TraceIdentifier, + RequestPath = context.Request.Path.ToString(), + HttpStatusCode = context.Response.StatusCode, + ProviderType = providerType, + ToolUsageJson = toolUsageJson, + ToolUsageCost = toolCost, + FailureReason = $"Unconfigured tools: {string.Join(", ", toolCostResult.UnconfiguredToolNames)}" + }); + UsageMetrics.BillingAuditEvents.WithLabels("ToolUsageMissingCostConfig", providerType).Inc(); + } + } + + // Extract function execution results from streaming context (richer data with execution status) + string? chatToolCallsJson = null; + decimal functionExecutionCost = 0m; + + if (endpointType == "chat" && context.Items.TryGetValue(HttpContextKeys.ChatFunctionCalls, out var functionResultsObj) + && functionResultsObj is List functionResults + && functionResults.Count > 0) + { + // Use richer function execution data (includes status, cost, execution ID) + chatToolCallsJson = FunctionExecutionSerializer.SerializeFunctionExecutionResults(functionResults); + + // Get total function cost from HttpContext + if (context.Items.TryGetValue(HttpContextKeys.ChatFunctionCost, out var funcCostObj) + && funcCostObj is decimal funcCost) + { + functionExecutionCost = funcCost; + } + + _logger.LogDebug("Streaming function executions detected: {Count} functions, total cost: {Cost:C}", + functionResults.Count, functionExecutionCost); + } + // Fallback to basic tool call info if no execution results available + else if (endpointType == "chat" && context.Items.TryGetValue("StreamingChatToolCalls", out var streamingToolCallsObj) + && streamingToolCallsObj is List streamingToolCalls + && streamingToolCalls.Count > 0) + { + // Convert to ChatToolCallData format (basic info only - no execution results) + var chatToolCallData = new ChatToolCallData + { + ToolCalls = streamingToolCalls.Select(tc => new ChatToolCallItem + { + Id = tc.Id, + Type = tc.Type, + FunctionName = tc.Function?.Name, + HasArguments = !string.IsNullOrEmpty(tc.Function?.Arguments) + }).ToList() + }; + chatToolCallsJson = UsageExtractor.SerializeChatToolCalls(chatToolCallData); + _logger.LogDebug("Streaming chat tool calls detected (basic): {ChatToolCallsJson}", chatToolCallsJson); + } + + // Calculate base cost and add tool cost (both provider tools and function executions) + // Prefer ID-based lookup if ModelCostId is available + decimal baseCost; + if (context.Items.TryGetValue(HttpContextKeys.ModelCostId, out var modelCostIdObj) && + modelCostIdObj is int modelCostId) + { + baseCost = await costCalculationService.CalculateCostByIdAsync(modelCostId, usage); + } + else + { + baseCost = await costCalculationService.CalculateCostAsync(model, usage); + } + var cost = baseCost + (toolCost ?? 0m) + functionExecutionCost; + + // Update metrics + UsageMetrics.UsageTrackingRequests.WithLabels(endpointType + "_stream", "success").Inc(); + + if (usage.PromptTokens.HasValue) + UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "prompt").Inc(usage.PromptTokens.Value); + + if (usage.CompletionTokens.HasValue) + UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "completion").Inc(usage.CompletionTokens.Value); + + if (usage.CachedInputTokens.HasValue && usage.CachedInputTokens.Value > 0) + UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "cached_input").Inc(usage.CachedInputTokens.Value); + + if (usage.CachedWriteTokens.HasValue && usage.CachedWriteTokens.Value > 0) + UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "cached_write").Inc(usage.CachedWriteTokens.Value); + + UsageMetrics.UsageTrackingCosts.WithLabels(model, providerType, endpointType + "_stream").Inc(Convert.ToDouble(cost)); + + // Record business metrics for Grafana dashboards (real-time counters) + var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 ? "success" : "error"; + BusinessMetricsService.RecordModelRequest(model, providerType, requestStatus); + BusinessMetricsService.RecordTokens(model, providerType, usage.PromptTokens ?? 0, usage.CompletionTokens ?? 0, usage.CachedInputTokens, usage.CachedWriteTokens); + BusinessMetricsService.RecordResponseTime(model, providerType, UsageExtractor.GetResponseTime(context) / 1000.0); + if (cost > 0) + { + BusinessMetricsService.RecordCost(providerType, model, endpointType, Convert.ToDouble(cost)); + } + + // Record prompt caching metrics + RecordPromptCachingMetrics(usage, model, providerType); + await RecordPromptCachingSavingsAsync(context, costCalculationService, model, usage); + + // Update spend only if there's a cost + if (cost > 0) + { + await SpendUpdateHelper.UpdateSpendAsync(virtualKeyId, cost, batchSpendService, virtualKeyService, _logger); + LogStreamingBilling(context, model, usage, cost, providerType, isEstimated, billingAuditService, toolUsageJson, toolCost); + } + else + { + UsageMetrics.ZeroCostEvents.WithLabels(model ?? "unknown", "streaming_zero").Inc(); + LogZeroCostBilling(context, model ?? "unknown", usage, cost, providerType, billingAuditService, toolUsageJson, toolCost); + } + + // Build metadata: prefer chat tool calls, fall back to provider tool usage + var metadata = chatToolCallsJson ?? toolUsageJson; + + // Always log the request regardless of cost + await LogRequestAsync(context, virtualKeyId, model ?? "unknown", usage, cost, requestLogService, metadata); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.cs index e872f0f30..936cdef63 100644 --- a/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.cs +++ b/Services/ConduitLLM.Gateway/Middleware/UsageTrackingMiddleware.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Text.Json; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; using ConduitLLM.Configuration.Interfaces; @@ -6,6 +8,7 @@ using ConduitLLM.Configuration; using ConduitLLM.Gateway.Constants; using ConduitLLM.Gateway.Controllers; +using ConduitLLM.Gateway.Metrics; using ConduitLLM.Gateway.Services; using ConduitLLM.Gateway.Utilities; using Prometheus; @@ -17,7 +20,7 @@ namespace ConduitLLM.Gateway.Middleware /// Middleware that tracks LLM usage by intercepting OpenAI-compatible responses. /// Extracts usage data from responses and updates virtual key spending. /// - public class UsageTrackingMiddleware + public partial class UsageTrackingMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; @@ -60,6 +63,9 @@ public async Task InvokeAsync( return; } + using var activity = GatewayRequestMetrics.StartUsageTrackingActivity( + UsageExtractor.DetermineRequestType(context.Request.Path)); + // For non-streaming responses, intercept the response body var originalBodyStream = context.Response.Body; @@ -183,7 +189,7 @@ await ProcessVideoResponseAsync(context, responseBody, costCalculationService, b // Extract usage data if present if (!root.TryGetProperty("usage", out var usageElement)) { - _logger.LogDebug("No usage data found in response for {Path}", context.Request.Path); + _logger.LogDebug("No usage data found in response for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); LogMissingUsageData(context, billingAuditService); return; } @@ -191,14 +197,14 @@ await ProcessVideoResponseAsync(context, responseBody, costCalculationService, b // Extract model name if (!root.TryGetProperty("model", out var modelElement)) { - _logger.LogWarning("No model found in response for {Path}", context.Request.Path); + _logger.LogWarning("No model found in response for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); return; } var model = modelElement.GetString(); if (string.IsNullOrEmpty(model)) { - _logger.LogWarning("Empty model name in response for {Path}", context.Request.Path); + _logger.LogWarning("Empty model name in response for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); return; } @@ -206,7 +212,7 @@ await ProcessVideoResponseAsync(context, responseBody, costCalculationService, b var usage = UsageExtractor.ExtractUsage(usageElement, _logger); if (usage == null) { - _logger.LogWarning("Failed to extract usage data for {Path}", context.Request.Path); + _logger.LogWarning("Failed to extract usage data for {Path}", LoggingSanitizer.S(context.Request.Path.ToString())); return; } @@ -235,28 +241,66 @@ await ProcessVideoResponseAsync(context, responseBody, costCalculationService, b cost = await costCalculationService.CalculateCostAsync(model, usage); } - // Reset stream position after JsonDocument.ParseAsync for tool usage extraction + // Read the response body once for all subsequent extractions responseBody.Seek(0, SeekOrigin.Begin); + string responseText; + using (var reader = new StreamReader(responseBody, leaveOpen: true)) + { + responseText = reader.ReadToEnd(); + } // Extract and calculate tool usage costs (provider-hosted tools like Groq code_interpreter) - var toolUsageData = ExtractToolUsageFromResponse(responseBody, providerTypeEnum); + var toolUsageData = UsageExtractor.ExtractToolUsage(responseText, providerTypeEnum, _logger); decimal? toolCost = null; string? toolUsageJson = null; + List? unconfiguredTools = null; if (toolUsageData != null) { - toolCost = await toolCostCalculationService.CalculateToolCostsAsync(toolUsageData, providerTypeEnum); + var toolCostResult = await toolCostCalculationService.CalculateToolCostsAsync(toolUsageData, providerTypeEnum); toolUsageJson = toolCostCalculationService.SerializeToolUsage(toolUsageData); - _logger.LogDebug("Tool usage detected: {ToolUsageJson}, Cost: ${ToolCost}", toolUsageJson, toolCost); + unconfiguredTools = toolCostResult.UnconfiguredToolNames; + + if (!toolCostResult.Failed) + { + toolCost = toolCostResult.TotalCost; + _logger.LogDebug("Tool usage detected: {ToolUsageJson}, Cost: ${ToolCost}", toolUsageJson, toolCost); + } + else + { + // Cost calculation failed โ€” record usage but log error + toolCost = 0m; + _logger.LogError("Tool cost calculation failed for provider {ProviderType}. " + + "Tool usage recorded but cost set to $0. Review provider tool configuration.", + providerTypeEnum); + } + + // Log audit event for unconfigured tools only when there's also billable cost from other tools. + // When toolCost == 0, BillingPolicyHandler.LogZeroCostBilling already emits ToolUsageMissingCostConfig. + if (toolCostResult.HasUnconfiguredTools && toolCost > 0) + { + var virtualKeyIdForAudit = (int)context.Items["VirtualKeyId"]!; + billingAuditService.LogBillingEvent(new Configuration.Entities.BillingAuditEvent + { + EventType = Configuration.Entities.BillingAuditEventType.ToolUsageMissingCostConfig, + VirtualKeyId = virtualKeyIdForAudit, + Model = model, + RequestId = context.TraceIdentifier, + RequestPath = context.Request.Path.ToString(), + HttpStatusCode = context.Response.StatusCode, + ProviderType = providerType, + ToolUsageJson = toolUsageJson, + ToolUsageCost = toolCost, + FailureReason = $"Unconfigured tools: {string.Join(", ", toolCostResult.UnconfiguredToolNames)}" + }); + UsageMetrics.BillingAuditEvents.WithLabels("ToolUsageMissingCostConfig", providerType).Inc(); + } } // Extract chat tool calls (user-defined function/tool calls in the response) string? chatToolCallsJson = null; if (endpointType == "chat") { - responseBody.Seek(0, SeekOrigin.Begin); - using var reader = new StreamReader(responseBody, leaveOpen: true); - var responseText = reader.ReadToEnd(); var chatToolCalls = UsageExtractor.ExtractChatToolCalls(responseText, _logger); chatToolCallsJson = UsageExtractor.SerializeChatToolCalls(chatToolCalls); @@ -278,18 +322,28 @@ await ProcessVideoResponseAsync(context, responseBody, costCalculationService, b if (usage.CompletionTokens.HasValue) UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "completion").Inc(usage.CompletionTokens.Value); + if (usage.CachedInputTokens.HasValue && usage.CachedInputTokens.Value > 0) + UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "cached_input").Inc(usage.CachedInputTokens.Value); + + if (usage.CachedWriteTokens.HasValue && usage.CachedWriteTokens.Value > 0) + UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "cached_write").Inc(usage.CachedWriteTokens.Value); + UsageMetrics.UsageTrackingCosts.WithLabels(model, providerType, endpointType).Inc(Convert.ToDouble(totalCost)); // Record business metrics for Grafana dashboards (real-time counters) var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 ? "success" : "error"; BusinessMetricsService.RecordModelRequest(model, providerType, requestStatus); - BusinessMetricsService.RecordTokens(model, providerType, usage.PromptTokens ?? 0, usage.CompletionTokens ?? 0); + BusinessMetricsService.RecordTokens(model, providerType, usage.PromptTokens ?? 0, usage.CompletionTokens ?? 0, usage.CachedInputTokens, usage.CachedWriteTokens); BusinessMetricsService.RecordResponseTime(model, providerType, UsageExtractor.GetResponseTime(context) / 1000.0); if (totalCost > 0) { BusinessMetricsService.RecordCost(providerType, model, endpointType, Convert.ToDouble(totalCost)); } + // Record prompt caching metrics + RecordPromptCachingMetrics(usage, model, providerType); + await RecordPromptCachingSavingsAsync(context, costCalculationService, model, usage); + // Update spend using batch service only if there's a cost if (totalCost > 0) { @@ -324,746 +378,6 @@ await ProcessVideoResponseAsync(context, responseBody, costCalculationService, b } } - private async Task TrackStreamingUsageAsync( - HttpContext context, - ICostCalculationService costCalculationService, - IBatchSpendUpdateService batchSpendService, - IRequestLogService requestLogService, - IVirtualKeyService virtualKeyService, - IBillingAuditService billingAuditService, - IToolCostCalculationService toolCostCalculationService) - { - var endpointType = UsageExtractor.DetermineRequestType(context.Request.Path); - - // Check if usage was estimated - var isEstimated = context.Items.TryGetValue("UsageIsEstimated", out var estimatedObj) && - estimatedObj is bool estimated && estimated; - - // For streaming responses, we need to rely on the SSE writer - // to have stored the usage data in HttpContext.Items - if (!context.Items.TryGetValue("StreamingUsage", out var usageObj) || - usageObj is not Usage usage) - { - _logger.LogDebug("No streaming usage data found for {Path}", context.Request.Path); - UsageMetrics.UsageTrackingFailures.WithLabels("no_streaming_usage", endpointType).Inc(); - LogMissingStreamingUsage(context, billingAuditService); - return; - } - - if (!context.Items.TryGetValue("StreamingModel", out var modelObj) || - modelObj is not string model) - { - _logger.LogWarning("No streaming model found for {Path}", context.Request.Path); - UsageMetrics.UsageTrackingFailures.WithLabels("no_streaming_model", endpointType).Inc(); - return; - } - - var virtualKeyId = (int)context.Items["VirtualKeyId"]!; - - // Get provider type for metrics - var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) - ? providerTypeObj?.ToString() ?? "unknown" - : "unknown"; - - // Parse provider type enum for tool usage - var providerTypeEnum = Enum.TryParse(providerType, true, out var parsedProviderType) - ? parsedProviderType - : ProviderType.OpenAI; - - // Extract tool usage from streaming context if available (provider-hosted tools) - var toolUsageData = context.Items.TryGetValue("StreamingToolUsage", out var toolObj) - ? toolObj as ToolUsageData - : null; - - decimal? toolCost = null; - string? toolUsageJson = null; - - if (toolUsageData != null) - { - toolCost = await toolCostCalculationService.CalculateToolCostsAsync(toolUsageData, providerTypeEnum); - toolUsageJson = toolCostCalculationService.SerializeToolUsage(toolUsageData); - _logger.LogDebug("Streaming tool usage detected: {ToolUsageJson}, Cost: ${ToolCost}", toolUsageJson, toolCost); - } - - // Extract function execution results from streaming context (richer data with execution status) - string? chatToolCallsJson = null; - decimal functionExecutionCost = 0m; - - if (endpointType == "chat" && context.Items.TryGetValue(HttpContextKeys.ChatFunctionCalls, out var functionResultsObj) - && functionResultsObj is List functionResults - && functionResults.Count > 0) - { - // Use richer function execution data (includes status, cost, execution ID) - chatToolCallsJson = FunctionExecutionSerializer.SerializeFunctionExecutionResults(functionResults); - - // Get total function cost from HttpContext - if (context.Items.TryGetValue(HttpContextKeys.ChatFunctionCost, out var funcCostObj) - && funcCostObj is decimal funcCost) - { - functionExecutionCost = funcCost; - } - - _logger.LogDebug("Streaming function executions detected: {Count} functions, total cost: {Cost:C}", - functionResults.Count, functionExecutionCost); - } - // Fallback to basic tool call info if no execution results available - else if (endpointType == "chat" && context.Items.TryGetValue("StreamingChatToolCalls", out var streamingToolCallsObj) - && streamingToolCallsObj is List streamingToolCalls - && streamingToolCalls.Count > 0) - { - // Convert to ChatToolCallData format (basic info only - no execution results) - var chatToolCallData = new ChatToolCallData - { - ToolCalls = streamingToolCalls.Select(tc => new ChatToolCallItem - { - Id = tc.Id, - Type = tc.Type, - FunctionName = tc.Function?.Name, - HasArguments = !string.IsNullOrEmpty(tc.Function?.Arguments) - }).ToList() - }; - chatToolCallsJson = UsageExtractor.SerializeChatToolCalls(chatToolCallData); - _logger.LogDebug("Streaming chat tool calls detected (basic): {ChatToolCallsJson}", chatToolCallsJson); - } - - // Calculate base cost and add tool cost (both provider tools and function executions) - // Prefer ID-based lookup if ModelCostId is available - decimal baseCost; - if (context.Items.TryGetValue(HttpContextKeys.ModelCostId, out var modelCostIdObj) && - modelCostIdObj is int modelCostId) - { - baseCost = await costCalculationService.CalculateCostByIdAsync(modelCostId, usage); - } - else - { - baseCost = await costCalculationService.CalculateCostAsync(model, usage); - } - var cost = baseCost + (toolCost ?? 0m) + functionExecutionCost; - - // Update metrics - UsageMetrics.UsageTrackingRequests.WithLabels(endpointType + "_stream", "success").Inc(); - - if (usage.PromptTokens.HasValue) - UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "prompt").Inc(usage.PromptTokens.Value); - - if (usage.CompletionTokens.HasValue) - UsageMetrics.UsageTrackingTokens.WithLabels(model, providerType, "completion").Inc(usage.CompletionTokens.Value); - - UsageMetrics.UsageTrackingCosts.WithLabels(model, providerType, endpointType + "_stream").Inc(Convert.ToDouble(cost)); - - // Record business metrics for Grafana dashboards (real-time counters) - var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 ? "success" : "error"; - BusinessMetricsService.RecordModelRequest(model, providerType, requestStatus); - BusinessMetricsService.RecordTokens(model, providerType, usage.PromptTokens ?? 0, usage.CompletionTokens ?? 0); - BusinessMetricsService.RecordResponseTime(model, providerType, UsageExtractor.GetResponseTime(context) / 1000.0); - if (cost > 0) - { - BusinessMetricsService.RecordCost(providerType, model, endpointType, Convert.ToDouble(cost)); - } - - // Update spend only if there's a cost - if (cost > 0) - { - await SpendUpdateHelper.UpdateSpendAsync(virtualKeyId, cost, batchSpendService, virtualKeyService, _logger); - LogStreamingBilling(context, model, usage, cost, providerType, isEstimated, billingAuditService, toolUsageJson, toolCost); - } - else - { - UsageMetrics.ZeroCostEvents.WithLabels(model ?? "unknown", "streaming_zero").Inc(); - LogZeroCostBilling(context, model ?? "unknown", usage, cost, providerType, billingAuditService, toolUsageJson, toolCost); - } - - // Build metadata: prefer chat tool calls, fall back to provider tool usage - var metadata = chatToolCallsJson ?? toolUsageJson; - - // Always log the request regardless of cost - await LogRequestAsync(context, virtualKeyId, model ?? "unknown", usage, cost, requestLogService, metadata); - } - - private async Task LogRequestAsync( - HttpContext context, - int virtualKeyId, - string model, - Usage usage, - decimal cost, - IRequestLogService requestLogService, - string? metadata = null) - { - try - { - var requestType = UsageExtractor.DetermineRequestType(context.Request.Path); - - // Extract provider info from HttpContext.Items (set by controllers) - int? providerId = context.Items.TryGetValue("ProviderId", out var providerIdObj) && providerIdObj is int pid - ? pid - : null; - var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) - ? providerTypeObj?.ToString() - : null; - - var logRequest = new LogRequestDto - { - VirtualKeyId = virtualKeyId, - ModelName = model, - ProviderId = providerId, - ProviderType = providerType, - RequestType = requestType, - InputTokens = usage.PromptTokens ?? 0, - OutputTokens = usage.CompletionTokens ?? 0, - Cost = cost, - ResponseTimeMs = UsageExtractor.GetResponseTime(context), - UserId = context.User?.Identity?.Name, - ClientIp = context.Connection.RemoteIpAddress?.ToString(), - RequestPath = context.Request.Path.ToString(), - StatusCode = context.Response.StatusCode, - Metadata = metadata - }; - - await requestLogService.LogRequestAsync(logRequest); - - _logger.LogInformation( - "Tracked usage for VirtualKey {VirtualKeyId}: Model={Model}, PromptTokens={PromptTokens}, CompletionTokens={CompletionTokens}, Cost={Cost:C}", - virtualKeyId, model, usage.PromptTokens, usage.CompletionTokens, cost); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to log request for VirtualKey {VirtualKeyId}", virtualKeyId); - // Don't throw - logging failure shouldn't break the request - } - } - - /// - /// Process function execution responses and log them with function-specific metadata. - /// - private async Task ProcessFunctionResponseAsync( - HttpContext context, - MemoryStream responseBody, - IBatchSpendUpdateService batchSpendService, - IRequestLogService requestLogService, - IVirtualKeyService virtualKeyService, - IBillingAuditService billingAuditService) - { - try - { - // Get virtual key ID - var virtualKeyId = (int)context.Items["VirtualKeyId"]!; - - // Get function configuration info from HttpContext.Items (set by FunctionsController) - var functionConfigId = context.Items.TryGetValue("FunctionConfigurationId", out var configIdObj) - ? configIdObj as int? ?? 0 - : 0; - var functionName = context.Items.TryGetValue("FunctionConfigurationName", out var nameObj) - ? nameObj?.ToString() ?? "unknown" - : "unknown"; - var executionId = context.Items.TryGetValue("FunctionExecutionId", out var execIdObj) - ? execIdObj as Guid? ?? Guid.Empty - : Guid.Empty; - - // Parse the response to get cost and state - using var jsonDocument = await JsonDocument.ParseAsync(responseBody); - var root = jsonDocument.RootElement; - - decimal cost = 0; - string state = "unknown"; - string? errorMessage = null; - - if (root.TryGetProperty("actualCost", out var actualCostElement)) - { - cost = actualCostElement.ValueKind == JsonValueKind.Number - ? actualCostElement.GetDecimal() - : 0; - } - else if (root.TryGetProperty("estimatedCost", out var estimatedCostElement)) - { - cost = estimatedCostElement.ValueKind == JsonValueKind.Number - ? estimatedCostElement.GetDecimal() - : 0; - } - - if (root.TryGetProperty("state", out var stateElement)) - { - state = stateElement.GetString() ?? "unknown"; - } - - if (root.TryGetProperty("errorMessage", out var errorElement) && errorElement.ValueKind == JsonValueKind.String) - { - errorMessage = errorElement.GetString(); - } - - // Build metadata JSON for function execution - var metadata = JsonSerializer.Serialize(new - { - type = "function", - functionConfigurationId = functionConfigId, - functionName, - executionId, - state, - errorMessage - }); - - // Get provider type for metrics - var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) - ? providerTypeObj?.ToString() ?? "unknown" - : "unknown"; - - // Update metrics - UsageMetrics.UsageTrackingRequests.WithLabels("function", "success").Inc(); - UsageMetrics.UsageTrackingCosts.WithLabels(functionName, providerType, "function").Inc(Convert.ToDouble(cost)); - - // Record business metrics for Grafana dashboards (real-time counters) - var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 ? "success" : "error"; - BusinessMetricsService.RecordModelRequest(functionName, providerType, requestStatus); - BusinessMetricsService.RecordResponseTime(functionName, providerType, UsageExtractor.GetResponseTime(context) / 1000.0); - if (cost > 0) - { - BusinessMetricsService.RecordCost(providerType, functionName, "function", Convert.ToDouble(cost)); - } - - // Update spend if there's a cost - if (cost > 0) - { - await SpendUpdateHelper.UpdateSpendAsync(virtualKeyId, cost, batchSpendService, virtualKeyService, _logger); - } - - // Create a Usage object with zero tokens (functions don't use tokens) - var usage = new Usage - { - PromptTokens = 0, - CompletionTokens = 0, - TotalTokens = 0 - }; - - // Log the request with function metadata - await LogRequestAsync(context, virtualKeyId, functionName, usage, cost, requestLogService, metadata); - - _logger.LogInformation( - "Tracked function execution for VirtualKey {VirtualKeyId}: Function={FunctionName}, ExecutionId={ExecutionId}, Cost={Cost:C}", - virtualKeyId, functionName, executionId, cost); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to process function response for usage tracking"); - UsageMetrics.UsageTrackingFailures.WithLabels("function_processing_error", "function").Inc(); - } - } - - /// - /// Process image generation responses and log them with image-specific metadata. - /// Image responses typically don't have standard usage data in the response, - /// so we extract details from HttpContext.Items (set by the controller) and the response data array. - /// - private async Task ProcessImageResponseAsync( - HttpContext context, - MemoryStream responseBody, - ICostCalculationService costCalculationService, - IBatchSpendUpdateService batchSpendService, - IRequestLogService requestLogService, - IVirtualKeyService virtualKeyService, - IBillingAuditService billingAuditService) - { - try - { - // Get virtual key ID - var virtualKeyId = (int)context.Items["VirtualKeyId"]!; - - // Get image request details from HttpContext.Items (set by ImagesController) - var quality = context.Items.TryGetValue(HttpContextKeys.ImageRequestQuality, out var qualityObj) - ? qualityObj?.ToString() - : null; - var size = context.Items.TryGetValue(HttpContextKeys.ImageRequestSize, out var sizeObj) - ? sizeObj?.ToString() - : null; - var requestedN = context.Items.TryGetValue(HttpContextKeys.ImageRequestN, out var nObj) - ? nObj as int? ?? 1 - : 1; - - // Get provider type for metrics - var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) - ? providerTypeObj?.ToString() ?? "unknown" - : "unknown"; - - // Parse the response to count actual images generated and check for usage/model data - int actualImageCount = requestedN; // Default to requested count - Usage? responseUsage = null; - string? responseModel = null; - - using var jsonDocument = await JsonDocument.ParseAsync(responseBody); - var root = jsonDocument.RootElement; - - // Try to get model from response (some providers may include it) - if (root.TryGetProperty("model", out var modelElement)) - { - responseModel = modelElement.GetString(); - } - - // Count actual images from the data array - if (root.TryGetProperty("data", out var dataArray) && dataArray.ValueKind == JsonValueKind.Array) - { - actualImageCount = dataArray.GetArrayLength(); - } - - // Check if the response includes usage data (some providers may include it) - if (root.TryGetProperty("usage", out var usageElement)) - { - responseUsage = UsageExtractor.ExtractUsage(usageElement, _logger); - } - - // Resolve model: prefer HttpContext.Items (original request model alias), then response, then "unknown" - var model = context.Items.TryGetValue(HttpContextKeys.ImageRequestModel, out var modelObj) - ? modelObj?.ToString() - : null; - if (string.IsNullOrEmpty(model)) - { - model = responseModel ?? "unknown"; - } - - // Build usage object - prefer response usage if available, otherwise construct from request data - var usage = responseUsage ?? new Usage - { - ImageCount = actualImageCount, - ImageQuality = quality, - ImageResolution = size - }; - - // Ensure image count is set even if response usage was used - if (!usage.ImageCount.HasValue || usage.ImageCount.Value == 0) - { - usage.ImageCount = actualImageCount; - } - if (string.IsNullOrEmpty(usage.ImageQuality)) - { - usage.ImageQuality = quality; - } - if (string.IsNullOrEmpty(usage.ImageResolution)) - { - usage.ImageResolution = size; - } - - // Calculate cost - prefer ID-based lookup if ModelCostId is available - decimal cost; - if (context.Items.TryGetValue(HttpContextKeys.ModelCostId, out var modelCostIdObj) && - modelCostIdObj is int modelCostId) - { - cost = await costCalculationService.CalculateCostByIdAsync(modelCostId, usage); - } - else - { - cost = await costCalculationService.CalculateCostAsync(model, usage); - } - - // Build metadata JSON for image generation - var metadata = JsonSerializer.Serialize(new - { - type = "image", - imageCount = actualImageCount, - quality = quality ?? "standard", - size = size ?? "unknown", - style = context.Items.TryGetValue("ImageRequestStyle", out var styleObj) ? styleObj?.ToString() : null - }); - - // Update metrics - UsageMetrics.UsageTrackingRequests.WithLabels("image", "success").Inc(); - UsageMetrics.UsageTrackingCosts.WithLabels(model, providerType, "image").Inc(Convert.ToDouble(cost)); - - // Record business metrics for Grafana dashboards (real-time counters) - var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 ? "success" : "error"; - BusinessMetricsService.RecordModelRequest(model, providerType, requestStatus); - BusinessMetricsService.RecordResponseTime(model, providerType, UsageExtractor.GetResponseTime(context) / 1000.0); - if (cost > 0) - { - BusinessMetricsService.RecordCost(providerType, model, "image", Convert.ToDouble(cost)); - } - - // Update spend if there's a cost - if (cost > 0) - { - await SpendUpdateHelper.UpdateSpendAsync(virtualKeyId, cost, batchSpendService, virtualKeyService, _logger); - LogSuccessfulBilling(context, model, usage, cost, providerType, billingAuditService); - } - else - { - UsageMetrics.ZeroCostEvents.WithLabels(model, "image_zero").Inc(); - LogZeroCostBilling(context, model, usage, cost, providerType, billingAuditService); - } - - // Log the request with image metadata - await LogRequestAsync(context, virtualKeyId, model, usage, cost, requestLogService, metadata); - - _logger.LogInformation( - "Tracked image generation for VirtualKey {VirtualKeyId}: Model={Model}, Images={ImageCount}, Quality={Quality}, Size={Size}, Cost={Cost:C}", - virtualKeyId, model, actualImageCount, quality ?? "standard", size ?? "default", cost); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to process image response for usage tracking"); - UsageMetrics.UsageTrackingFailures.WithLabels("image_processing_error", "image").Inc(); - } - } - - /// - /// Process video generation responses and log them with video-specific metadata. - /// Video responses typically don't have standard usage data in the response, - /// so we extract details from HttpContext.Items (set by the controller) and the response data. - /// - private async Task ProcessVideoResponseAsync( - HttpContext context, - MemoryStream responseBody, - ICostCalculationService costCalculationService, - IBatchSpendUpdateService batchSpendService, - IRequestLogService requestLogService, - IVirtualKeyService virtualKeyService, - IBillingAuditService billingAuditService) - { - try - { - // Get virtual key ID - var virtualKeyId = (int)context.Items["VirtualKeyId"]!; - - // Get video request details from HttpContext.Items (set by VideosController) - var size = context.Items.TryGetValue(HttpContextKeys.VideoRequestSize, out var sizeObj) - ? sizeObj?.ToString() - : null; - var requestedDuration = context.Items.TryGetValue(HttpContextKeys.VideoRequestDuration, out var durationObj) - ? durationObj as int? - : null; - var requestedN = context.Items.TryGetValue(HttpContextKeys.VideoRequestN, out var nObj) - ? nObj as int? ?? 1 - : 1; - var fps = context.Items.TryGetValue(HttpContextKeys.VideoRequestFps, out var fpsObj) - ? fpsObj as int? - : null; - var style = context.Items.TryGetValue(HttpContextKeys.VideoRequestStyle, out var styleObj) - ? styleObj?.ToString() - : null; - - // Get pricing parameters for rules-based pricing - var pricingParameters = context.Items.TryGetValue(HttpContextKeys.VideoRequestPricingParameters, out var paramsObj) - ? paramsObj as Dictionary - : null; - - // Get provider type for metrics - var providerType = context.Items.TryGetValue("ProviderType", out var providerTypeObj) - ? providerTypeObj?.ToString() ?? "unknown" - : "unknown"; - - // Parse the response to check for usage/model data and actual video count - int actualVideoCount = requestedN; - Usage? responseUsage = null; - string? responseModel = null; - double? actualDuration = null; - string? actualResolution = null; - string? taskId = null; - - responseBody.Seek(0, SeekOrigin.Begin); - using var jsonDocument = await JsonDocument.ParseAsync(responseBody); - var root = jsonDocument.RootElement; - - // Try to get task ID from async response (for cost correction later) - if (root.TryGetProperty("taskId", out var taskIdElement)) - { - taskId = taskIdElement.GetString(); - } - - // Try to get model from response - if (root.TryGetProperty("model", out var modelElement)) - { - responseModel = modelElement.GetString(); - } - - // Count actual videos from the data array and extract metadata - if (root.TryGetProperty("data", out var dataArray) && dataArray.ValueKind == JsonValueKind.Array) - { - actualVideoCount = dataArray.GetArrayLength(); - - // Extract metadata from first video if available - if (actualVideoCount > 0) - { - var firstVideo = dataArray[0]; - if (firstVideo.TryGetProperty("metadata", out var videoMetadata)) - { - if (videoMetadata.TryGetProperty("duration", out var durationEl)) - { - actualDuration = durationEl.GetDouble(); - } - if (videoMetadata.TryGetProperty("width", out var widthEl) && - videoMetadata.TryGetProperty("height", out var heightEl)) - { - actualResolution = $"{widthEl.GetInt32()}x{heightEl.GetInt32()}"; - } - } - } - } - - // Check if the response includes usage data - if (root.TryGetProperty("usage", out var usageElement)) - { - responseUsage = UsageExtractor.ExtractUsage(usageElement, _logger); - } - - // Resolve model: prefer HttpContext.Items (original request model alias), then response, then "unknown" - var model = context.Items.TryGetValue(HttpContextKeys.VideoRequestModel, out var modelObj) - ? modelObj?.ToString() - : null; - if (string.IsNullOrEmpty(model)) - { - model = responseModel ?? "unknown"; - } - - // Build usage object - prefer response usage if available, otherwise construct from request/response data - var usage = responseUsage ?? new Usage(); - - // Set video duration (prefer actual from response, then requested) - if (!usage.VideoDurationSeconds.HasValue) - { - usage.VideoDurationSeconds = actualDuration ?? requestedDuration; - } - - // Set video resolution (prefer actual from response, then requested) - if (string.IsNullOrEmpty(usage.VideoResolution)) - { - usage.VideoResolution = actualResolution ?? size; - } - - // Set pricing parameters for rules-based pricing - if (pricingParameters != null && pricingParameters.Count > 0) - { - usage.PricingParameters = pricingParameters; - } - - // Calculate cost - prefer ID-based lookup if ModelCostId is available - decimal cost; - if (context.Items.TryGetValue(HttpContextKeys.ModelCostId, out var modelCostIdObj) && - modelCostIdObj is int modelCostId) - { - cost = await costCalculationService.CalculateCostByIdAsync(modelCostId, usage); - } - else - { - cost = await costCalculationService.CalculateCostAsync(model, usage); - } - - // Build metadata JSON for video generation - // Include taskId for async requests so we can update cost/duration later - var metadata = JsonSerializer.Serialize(new - { - type = "video", - taskId = taskId, - videoCount = actualVideoCount, - durationSeconds = usage.VideoDurationSeconds, - resolution = usage.VideoResolution ?? "unknown", - fps = fps, - style = style, - pricingParametersUsed = pricingParameters?.Keys.ToArray() - }); - - // Update metrics - UsageMetrics.UsageTrackingRequests.WithLabels("video", "success").Inc(); - UsageMetrics.UsageTrackingCosts.WithLabels(model, providerType, "video").Inc(Convert.ToDouble(cost)); - - // Record business metrics for Grafana dashboards (real-time counters) - var requestStatus = context.Response.StatusCode >= 200 && context.Response.StatusCode < 300 ? "success" : "error"; - BusinessMetricsService.RecordModelRequest(model, providerType, requestStatus); - BusinessMetricsService.RecordResponseTime(model, providerType, UsageExtractor.GetResponseTime(context) / 1000.0); - if (cost > 0) - { - BusinessMetricsService.RecordCost(providerType, model, "video", Convert.ToDouble(cost)); - } - - // Update spend if there's a cost - if (cost > 0) - { - await SpendUpdateHelper.UpdateSpendAsync(virtualKeyId, cost, batchSpendService, virtualKeyService, _logger); - LogSuccessfulBilling(context, model, usage, cost, providerType, billingAuditService); - } - else - { - UsageMetrics.ZeroCostEvents.WithLabels(model, "video_zero").Inc(); - LogZeroCostBilling(context, model, usage, cost, providerType, billingAuditService); - } - - // Log the request with video metadata - await LogRequestAsync(context, virtualKeyId, model, usage, cost, requestLogService, metadata); - - _logger.LogInformation( - "Tracked video generation for VirtualKey {VirtualKeyId}: Model={Model}, Videos={VideoCount}, Duration={Duration}s, Resolution={Resolution}, Cost={Cost:C}", - virtualKeyId, model, actualVideoCount, usage.VideoDurationSeconds ?? 0, usage.VideoResolution ?? "unknown", cost); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to process video response for usage tracking"); - UsageMetrics.UsageTrackingFailures.WithLabels("video_processing_error", "video").Inc(); - } - } - - #region Billing Audit Logging - - private async Task LogBillingDecisionAsync(HttpContext context, IBillingAuditService billingAuditService) - { - await BillingPolicyHandler.LogBillingDecisionAsync(context, billingAuditService, _logger); - } - - private void LogSuccessfulBilling(HttpContext context, string model, Usage usage, decimal cost, - string providerType, IBillingAuditService billingAuditService, string? toolUsageJson = null, decimal? toolCost = null) - { - BillingPolicyHandler.LogSuccessfulBilling(context, model, usage, cost, providerType, billingAuditService, _logger, toolUsageJson, toolCost); - } - - /// - /// Extracts tool usage data from the response body. - /// - /// The response body stream - /// The provider type - /// Tool usage data or null if no tools were used - private ToolUsageData? ExtractToolUsageFromResponse(MemoryStream responseBody, ProviderType providerType) - { - try - { - responseBody.Seek(0, SeekOrigin.Begin); - using var reader = new StreamReader(responseBody, leaveOpen: true); - var responseText = reader.ReadToEnd(); - - return UsageExtractor.ExtractToolUsage(responseText, providerType, _logger); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to extract tool usage from response"); - return null; - } - } - - private void LogZeroCostBilling(HttpContext context, string model, Usage usage, decimal cost, - string providerType, IBillingAuditService billingAuditService, string? toolUsageJson = null, decimal? toolCost = null) - { - BillingPolicyHandler.LogZeroCostBilling(context, model, usage, cost, providerType, billingAuditService, toolUsageJson, toolCost, _logger); - } - - private void LogMissingUsageData(HttpContext context, IBillingAuditService billingAuditService) - { - BillingPolicyHandler.LogMissingUsageData(context, billingAuditService); - } - - private void LogStreamingBilling(HttpContext context, string model, Usage usage, decimal cost, - string providerType, bool isEstimated, IBillingAuditService billingAuditService, string? toolUsageJson = null, decimal? toolCost = null) - { - BillingPolicyHandler.LogStreamingBilling(context, model, usage, cost, providerType, isEstimated, billingAuditService, _logger, toolUsageJson, toolCost); - } - - private void LogMissingStreamingUsage(HttpContext context, IBillingAuditService billingAuditService) - { - BillingPolicyHandler.LogMissingStreamingUsage(context, billingAuditService); - } - - private void LogJsonParseError(HttpContext context, Exception ex, IBillingAuditService billingAuditService) - { - BillingPolicyHandler.LogJsonParseError(context, ex, billingAuditService); - } - - private void LogUnexpectedError(HttpContext context, Exception ex, IBillingAuditService billingAuditService) - { - BillingPolicyHandler.LogUnexpectedError(context, ex, billingAuditService); - } - - #endregion } /// diff --git a/Services/ConduitLLM.Gateway/Middleware/VirtualKeyAuthenticationMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/VirtualKeyAuthenticationMiddleware.cs deleted file mode 100644 index 992795d30..000000000 --- a/Services/ConduitLLM.Gateway/Middleware/VirtualKeyAuthenticationMiddleware.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.Security.Claims; - -using ConduitLLM.Core.Interfaces; - -namespace ConduitLLM.Gateway.Middleware -{ - /// - /// Middleware that handles Virtual Key authentication for Gateway API endpoints - /// - public class VirtualKeyAuthenticationMiddleware - { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the VirtualKeyAuthenticationMiddleware - /// - public VirtualKeyAuthenticationMiddleware( - RequestDelegate next, - ILogger logger) - { - _next = next; - _logger = logger; - } - - /// - /// Processes the HTTP request for Virtual Key authentication - /// - public async Task InvokeAsync(HttpContext context, IVirtualKeyService virtualKeyService) - { - // Skip authentication for excluded paths - if (IsPathExcluded(context.Request.Path)) - { - await _next(context); - return; - } - - // Extract Virtual Key from request - var virtualKey = ExtractVirtualKey(context); - - if (string.IsNullOrEmpty(virtualKey)) - { - _logger.LogWarning("Missing Virtual Key in request to {Path} from IP {IP}", - context.Request.Path, - context.Connection.RemoteIpAddress); - - context.Response.StatusCode = 401; - await context.Response.WriteAsJsonAsync(new { error = "Missing or invalid authentication" }); - return; - } - - _logger.LogInformation("Extracted Virtual Key: {KeyPrefix}... (length: {Length})", - virtualKey.Length > 10 ? virtualKey.Substring(0, 10) : virtualKey, - virtualKey.Length); - - // Validate Virtual Key - var validatedKey = await virtualKeyService.ValidateVirtualKeyAsync(virtualKey); - - if (validatedKey == null) - { - _logger.LogWarning("Invalid Virtual Key attempt for key {Key} from IP {IP}", - virtualKey.Substring(0, Math.Min(10, virtualKey.Length)) + "...", - context.Connection.RemoteIpAddress); - - // Store failed attempt info for security service to track - context.Items["FailedAuth"] = true; - context.Items["FailedAuthReason"] = "Invalid Virtual Key"; - context.Items["AttemptedKey"] = virtualKey; - - context.Response.StatusCode = 401; - await context.Response.WriteAsJsonAsync(new { error = "Invalid Virtual Key" }); - return; - } - - // Check if key is enabled - if (!validatedKey.IsEnabled) - { - _logger.LogWarning("Disabled Virtual Key attempt for key {KeyName} from IP {IP}", - validatedKey.KeyName, - context.Connection.RemoteIpAddress); - - // Store failed attempt info for security service to track - context.Items["FailedAuth"] = true; - context.Items["FailedAuthReason"] = "Virtual Key is disabled"; - context.Items["AttemptedKey"] = virtualKey; - - context.Response.StatusCode = 401; - await context.Response.WriteAsJsonAsync(new { error = "Virtual Key is disabled" }); - return; - } - - // Check if key is expired - if (validatedKey.ExpiresAt.HasValue && validatedKey.ExpiresAt.Value < DateTime.UtcNow) - { - _logger.LogWarning("Expired Virtual Key attempt for key {KeyName} from IP {IP}", - validatedKey.KeyName, - context.Connection.RemoteIpAddress); - - // Store failed attempt info for security service to track - context.Items["FailedAuth"] = true; - context.Items["FailedAuthReason"] = "Virtual Key has expired"; - context.Items["AttemptedKey"] = virtualKey; - - context.Response.StatusCode = 401; - await context.Response.WriteAsJsonAsync(new { error = "Virtual Key has expired" }); - return; - } - - // Set authentication context - var claims = new[] - { - new Claim("VirtualKeyId", validatedKey.Id.ToString()), - new Claim("VirtualKeyName", validatedKey.KeyName ?? ""), - new Claim(ClaimTypes.AuthenticationMethod, "VirtualKey") - }; - - var identity = new ClaimsIdentity(claims, "VirtualKey"); - var principal = new ClaimsPrincipal(identity); - context.User = principal; - - // Store Virtual Key info in context for downstream use - context.Items["VirtualKey"] = virtualKey; - context.Items["VirtualKeyId"] = validatedKey.Id; - context.Items["VirtualKeyName"] = validatedKey.KeyName; - context.Items["VirtualKeyEntity"] = validatedKey; - - // Clear any previous failed auth tracking for this IP (will be handled by SecurityService) - context.Items["AuthSuccess"] = true; - - _logger.LogDebug("Virtual Key {KeyName} authenticated successfully for {Path}", - validatedKey.KeyName, - context.Request.Path); - - await _next(context); - } - - private string? ExtractVirtualKey(HttpContext context) - { - // Check Authorization header (Bearer token) - var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); - if (!string.IsNullOrEmpty(authHeader)) - { - if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return authHeader.Substring("Bearer ".Length).Trim(); - } - } - - // Check api-key header (OpenAI compatible) - var apiKeyHeader = context.Request.Headers["api-key"].FirstOrDefault(); - if (!string.IsNullOrEmpty(apiKeyHeader)) - { - return apiKeyHeader; - } - - // Check X-API-Key header (alternative) - var xApiKeyHeader = context.Request.Headers["X-API-Key"].FirstOrDefault(); - if (!string.IsNullOrEmpty(xApiKeyHeader)) - { - return xApiKeyHeader; - } - - // Check X-Virtual-Key header (legacy support) - var xVirtualKeyHeader = context.Request.Headers["X-Virtual-Key"].FirstOrDefault(); - if (!string.IsNullOrEmpty(xVirtualKeyHeader)) - { - return xVirtualKeyHeader; - } - - return null; - } - - private bool IsPathExcluded(PathString path) - { - // Exclude health checks, metrics, documentation, public media, and SignalR hubs - var excludedPaths = new[] - { - "/health", - "/health/live", - "/health/ready", - "/metrics", - "/swagger", - "/_framework", - "/favicon.ico", - "/v1/media/public", - "/hubs" // SignalR hubs use different authentication mechanism - }; - - return excludedPaths.Any(excluded => - path.StartsWithSegments(excluded, StringComparison.OrdinalIgnoreCase)); - } - } - - /// - /// Extension methods for VirtualKeyAuthenticationMiddleware - /// - public static class VirtualKeyAuthenticationMiddlewareExtensions - { - /// - /// Adds Virtual Key authentication middleware to the pipeline - /// - public static IApplicationBuilder UseVirtualKeyAuthentication(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Middleware/VirtualKeyRateLimitMiddleware.cs b/Services/ConduitLLM.Gateway/Middleware/VirtualKeyRateLimitMiddleware.cs new file mode 100644 index 000000000..c2b7d255e --- /dev/null +++ b/Services/ConduitLLM.Gateway/Middleware/VirtualKeyRateLimitMiddleware.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using ConduitLLM.Core.Models; +using ConduitLLM.Core.Services; +using ConduitLLM.Gateway.Metrics; + +namespace ConduitLLM.Gateway.Middleware +{ + /// + /// Enforces per-virtual-key sliding-window RPM/RPD rate limits via + /// . The authenticated key's hash and + /// configured limits are stashed in HttpContext.Items by + /// VirtualKeyAuthenticationHandler, so this middleware does no DB lookups. + /// + /// + /// Behavior contract: + /// - Excluded paths (health, metrics, public media, SignalR hubs) are passed through. + /// - Requests not authenticated via the VirtualKey scheme (e.g., Backend service-to-service) + /// are passed through โ€” they have no VirtualKey.KeyHash in HttpContext.Items. + /// - Keys with RateLimitRpm == null AND RateLimitRpd == null are unlimited. + /// - On rate-limit rejection, returns 429 with Retry-After and an OpenAI-shaped error body. + /// - On any internal exception (Redis unavailable, etc.), logs a warning and lets the request + /// through (fail open) โ€” preferable to tanking the gateway. + /// + public class VirtualKeyRateLimitMiddleware + { + private static readonly string[] ExcludedPathPrefixes = + { + "/health", + "/metrics", + "/v1/media/public", + "/hubs" + }; + + private readonly RequestDelegate _next; + private readonly IVirtualKeyRateLimitService _rateLimitService; + private readonly ILogger _logger; + + public VirtualKeyRateLimitMiddleware( + RequestDelegate next, + IVirtualKeyRateLimitService rateLimitService, + ILogger logger) + { + _next = next; + _rateLimitService = rateLimitService; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + if (ShouldSkip(context)) + { + await _next(context); + return; + } + + // Only authenticated VirtualKey-scheme requests carry a KeyHash in Items. + // Backend-scheme and unauthenticated requests pass through unrestricted here. + if (context.Items["VirtualKey.KeyHash"] is not string keyHash || string.IsNullOrEmpty(keyHash)) + { + await _next(context); + return; + } + + var rpmLimit = context.Items["VirtualKey.RateLimitRpm"] as int?; + var rpdLimit = context.Items["VirtualKey.RateLimitRpd"] as int?; + + // Null/zero on both = unlimited; skip the Redis round-trip. + if (!HasConfiguredLimit(rpmLimit) && !HasConfiguredLimit(rpdLimit)) + { + await _next(context); + return; + } + + RateLimitCheckResult? result = null; + try + { + result = await _rateLimitService.CheckRateLimitAsync(keyHash, rpmLimit, rpdLimit); + } + catch (Exception ex) + { + // Fail open: rate limiting is defense-in-depth; never tank the request path. + _logger.LogWarning(ex, "Rate limit check failed for virtual key {KeyHashPrefix}; allowing request", + SafeKeyPrefix(keyHash)); + GatewayRateLimitMetrics.RecordError(); + await _next(context); + return; + } + + SetRateLimitHeaders(context, result); + + if (!result.IsAllowed) + { + GatewayRateLimitMetrics.RecordRejected(result.LimitType); + await WriteRateLimitedResponseAsync(context, result); + return; + } + + GatewayRateLimitMetrics.RecordAllowed(result.LimitType); + await _next(context); + } + + private static bool ShouldSkip(HttpContext context) + { + var path = context.Request.Path.Value; + if (string.IsNullOrEmpty(path)) return false; + foreach (var prefix in ExcludedPathPrefixes) + { + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + private static bool HasConfiguredLimit(int? limit) => limit.HasValue && limit.Value > 0; + + private static void SetRateLimitHeaders(HttpContext context, RateLimitCheckResult result) + { + // Headers are informational โ€” set on both allow and deny so clients can pace themselves. + context.Response.Headers["X-RateLimit-Limit"] = result.Limit.ToString(); + context.Response.Headers["X-RateLimit-Remaining"] = result.RequestsRemaining.ToString(); + context.Response.Headers["X-RateLimit-Reset"] = ((DateTimeOffset)result.ResetsAt).ToUnixTimeSeconds().ToString(); + if (!string.IsNullOrEmpty(result.LimitType)) + { + context.Response.Headers["X-RateLimit-Scope"] = result.LimitType; + } + } + + private static async Task WriteRateLimitedResponseAsync(HttpContext context, RateLimitCheckResult result) + { + var retryAfterSeconds = Math.Max(1, (int)(result.ResetsAt - DateTime.UtcNow).TotalSeconds); + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + context.Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); + context.Response.ContentType = "application/json"; + + var error = new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = $"{result.LimitType} rate limit exceeded ({result.Limit} requests). Retry after {retryAfterSeconds} seconds.", + Type = "rate_limit_exceeded", + Code = "rate_limit_exceeded" + } + }; + + await JsonSerializer.SerializeAsync(context.Response.Body, error); + } + + private static string SafeKeyPrefix(string keyHash) + { + return keyHash.Length <= 8 ? keyHash : keyHash[..8]; + } + } + + public static class VirtualKeyRateLimitMiddlewareExtensions + { + public static IApplicationBuilder UseVirtualKeyRateLimiting(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Models/EphemeralKeyData.cs b/Services/ConduitLLM.Gateway/Models/EphemeralKeyData.cs index d0155fcbc..04ca25b19 100644 --- a/Services/ConduitLLM.Gateway/Models/EphemeralKeyData.cs +++ b/Services/ConduitLLM.Gateway/Models/EphemeralKeyData.cs @@ -1,35 +1,17 @@ +using ConduitLLM.Core.Models; + namespace ConduitLLM.Gateway.Models { /// /// Represents the data stored in Redis for an ephemeral API key /// - public class EphemeralKeyData + public class EphemeralKeyData : EphemeralKeyDataBase { - /// - /// The ephemeral key token - /// - public string Key { get; set; } = string.Empty; - /// /// The virtual key ID that this ephemeral key is associated with /// public int VirtualKeyId { get; set; } - /// - /// When the ephemeral key was created - /// - public DateTimeOffset CreatedAt { get; set; } - - /// - /// When the ephemeral key expires - /// - public DateTimeOffset ExpiresAt { get; set; } - - /// - /// Whether this key has been consumed (used) - /// - public bool IsConsumed { get; set; } - /// /// Optional metadata about the ephemeral key /// diff --git a/Services/ConduitLLM.Gateway/Models/OpenAIErrorResponse.cs b/Services/ConduitLLM.Gateway/Models/OpenAIErrorResponse.cs deleted file mode 100644 index ecc6d372a..000000000 --- a/Services/ConduitLLM.Gateway/Models/OpenAIErrorResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Type aliases to use the Core models -namespace ConduitLLM.Gateway.Models -{ - // This namespace now uses the Core models via type aliases - // to maintain backward compatibility while avoiding duplication -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Models/PendingAcknowledgment.cs b/Services/ConduitLLM.Gateway/Models/PendingAcknowledgment.cs index 44946fe8a..1c1e03b42 100644 --- a/Services/ConduitLLM.Gateway/Models/PendingAcknowledgment.cs +++ b/Services/ConduitLLM.Gateway/Models/PendingAcknowledgment.cs @@ -1,3 +1,5 @@ +using ConduitLLM.Core.Models.SignalR; + namespace ConduitLLM.Gateway.Models { /// diff --git a/Services/ConduitLLM.Gateway/Models/QueuedMessage.cs b/Services/ConduitLLM.Gateway/Models/QueuedMessage.cs index 24c1ced08..8b7658e46 100644 --- a/Services/ConduitLLM.Gateway/Models/QueuedMessage.cs +++ b/Services/ConduitLLM.Gateway/Models/QueuedMessage.cs @@ -1,3 +1,5 @@ +using ConduitLLM.Core.Models.SignalR; + namespace ConduitLLM.Gateway.Models { /// diff --git a/Services/ConduitLLM.Gateway/Models/SignalRMessage.cs b/Services/ConduitLLM.Gateway/Models/SignalRMessage.cs deleted file mode 100644 index bfc19f950..000000000 --- a/Services/ConduitLLM.Gateway/Models/SignalRMessage.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace ConduitLLM.Gateway.Models -{ - /// - /// Base class for all SignalR messages that require acknowledgment - /// - public abstract class SignalRMessage - { - /// - /// Unique identifier for the message - /// - public string MessageId { get; set; } = Guid.NewGuid().ToString(); - - /// - /// Timestamp when the message was created - /// - public DateTime Timestamp { get; set; } = DateTime.UtcNow; - - /// - /// Optional correlation ID for tracking related messages - /// - public string? CorrelationId { get; set; } - - /// - /// Number of times this message has been retried - /// - public int RetryCount { get; set; } - - /// - /// Type of the message for routing and processing - /// - public abstract string MessageType { get; } - - /// - /// Priority of the message (higher values = higher priority) - /// - public int Priority { get; set; } = 0; - - /// - /// Indicates if this is a critical message that must be delivered - /// - public bool IsCritical { get; set; } = false; - - /// - /// Expiration time for the message (null = no expiration) - /// - public DateTime? ExpiresAt { get; set; } - - /// - /// Checks if the message has expired - /// - public bool IsExpired => ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value; - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Models/TaskCompletedMessage.cs b/Services/ConduitLLM.Gateway/Models/TaskCompletedMessage.cs index 54bdc8bef..a0ec8d105 100644 --- a/Services/ConduitLLM.Gateway/Models/TaskCompletedMessage.cs +++ b/Services/ConduitLLM.Gateway/Models/TaskCompletedMessage.cs @@ -1,3 +1,5 @@ +using ConduitLLM.Core.Models.SignalR; + namespace ConduitLLM.Gateway.Models { /// diff --git a/Services/ConduitLLM.Gateway/Models/TaskProgressMessage.cs b/Services/ConduitLLM.Gateway/Models/TaskProgressMessage.cs index a7337a866..7e8827564 100644 --- a/Services/ConduitLLM.Gateway/Models/TaskProgressMessage.cs +++ b/Services/ConduitLLM.Gateway/Models/TaskProgressMessage.cs @@ -1,3 +1,5 @@ +using ConduitLLM.Core.Models.SignalR; + namespace ConduitLLM.Gateway.Models { /// diff --git a/Services/ConduitLLM.Gateway/Options/SecurityOptions.cs b/Services/ConduitLLM.Gateway/Options/SecurityOptions.cs deleted file mode 100644 index 8d1fc1b85..000000000 --- a/Services/ConduitLLM.Gateway/Options/SecurityOptions.cs +++ /dev/null @@ -1,246 +0,0 @@ -namespace ConduitLLM.Gateway.Options -{ - /// - /// Security configuration options for the Gateway API - /// - public class SecurityOptions - { - /// - /// IP filtering options - /// - public IpFilteringOptions IpFiltering { get; set; } = new(); - - /// - /// Rate limiting options for IP-based limits (not Virtual Key limits) - /// - public RateLimitingOptions RateLimiting { get; set; } = new(); - - /// - /// Failed authentication protection options - /// - public FailedAuthOptions FailedAuth { get; set; } = new(); - - /// - /// Security headers options - /// - public SecurityHeadersOptions Headers { get; set; } = new(); - - /// - /// Whether to use distributed tracking via Redis - /// - public bool UseDistributedTracking { get; set; } = true; - - /// - /// Virtual Key specific options - /// - public VirtualKeyOptions VirtualKey { get; set; } = new(); - } - - /// - /// IP filtering configuration - /// - public class IpFilteringOptions - { - /// - /// Whether IP filtering is enabled - /// - public bool Enabled { get; set; } = true; - - /// - /// Filter mode: "permissive" (blacklist) or "restrictive" (whitelist) - /// - public string Mode { get; set; } = "permissive"; - - /// - /// Whether to allow private/intranet IPs - /// - public bool AllowPrivateIps { get; set; } = true; - - /// - /// IP addresses or CIDR ranges to whitelist - /// - public List Whitelist { get; set; } = new(); - - /// - /// IP addresses or CIDR ranges to blacklist - /// - public List Blacklist { get; set; } = new(); - - /// - /// Paths to exclude from IP filtering - /// - public List ExcludedPaths { get; set; } = new() { "/health", "/metrics" }; - } - - /// - /// Rate limiting configuration for IP-based limits - /// - public class RateLimitingOptions - { - /// - /// Whether IP-based rate limiting is enabled - /// - public bool Enabled { get; set; } = true; - - /// - /// Maximum requests per IP per window - /// - public int MaxRequests { get; set; } = 1000; - - /// - /// Time window in seconds - /// - public int WindowSeconds { get; set; } = 60; - - /// - /// Discovery-specific rate limiting configuration - /// - public DiscoveryRateLimitOptions Discovery { get; set; } = new(); - - /// - /// Paths to exclude from rate limiting - /// - public List ExcludedPaths { get; set; } = new() { "/health", "/metrics", "/swagger" }; - } - - /// - /// Discovery API specific rate limiting configuration - /// - public class DiscoveryRateLimitOptions - { - /// - /// Whether discovery-specific rate limiting is enabled - /// - public bool Enabled { get; set; } = true; - - /// - /// Maximum discovery requests per IP per window - /// - public int MaxRequests { get; set; } = 500; // Increased from 100 with bulk API - - /// - /// Time window in seconds for discovery requests - /// - public int WindowSeconds { get; set; } = 300; // 5 minutes - - /// - /// Paths that count towards discovery rate limits - /// - public List DiscoveryPaths { get; set; } = new() - { - "/v1/discovery/", - "/v1/models/", - "/capabilities/" - }; - - /// - /// Maximum capability check requests per model per IP per window - /// - public int MaxCapabilityChecksPerModel { get; set; } = 20; // Increased from 5 - - /// - /// Time window for per-model capability checks in seconds - /// - public int CapabilityCheckWindowSeconds { get; set; } = 600; // 10 minutes - } - - /// - /// Failed authentication protection configuration - /// - public class FailedAuthOptions - { - /// - /// Maximum failed authentication attempts per IP before banning - /// - public int MaxAttempts { get; set; } = 10; - - /// - /// Duration to ban an IP in minutes - /// - public int BanDurationMinutes { get; set; } = 30; - - /// - /// Whether to track failed attempts across all Virtual Keys - /// - public bool TrackAcrossKeys { get; set; } = true; - } - - /// - /// Security headers configuration - /// - public class SecurityHeadersOptions - { - /// - /// Whether to add X-Content-Type-Options header - /// - public bool XContentTypeOptions { get; set; } = true; - - /// - /// Whether to add X-XSS-Protection header - /// - public bool XXssProtection { get; set; } = false; // Not needed for API - - /// - /// HSTS configuration - /// - public HstsOptions Hsts { get; set; } = new(); - - /// - /// Custom headers to add - /// - public Dictionary CustomHeaders { get; set; } = new(); - } - - /// - /// HSTS configuration - /// - public class HstsOptions - { - /// - /// Whether HSTS is enabled - /// - public bool Enabled { get; set; } = true; - - /// - /// HSTS max age in seconds - /// - public int MaxAge { get; set; } = 31536000; // 1 year - } - - /// - /// Virtual Key specific options - /// - public class VirtualKeyOptions - { - /// - /// Whether to enforce Virtual Key rate limits from database - /// - public bool EnforceRateLimits { get; set; } = true; - - /// - /// Whether to enforce Virtual Key budget limits - /// - public bool EnforceBudgetLimits { get; set; } = true; - - /// - /// Whether to enforce model access restrictions - /// - public bool EnforceModelRestrictions { get; set; } = true; - - /// - /// Cache duration for Virtual Key validation in seconds - /// - public int ValidationCacheSeconds { get; set; } = 60; - - /// - /// Headers to check for Virtual Key (in order of preference) - /// - public List KeyHeaders { get; set; } = new() - { - "Authorization", - "api-key", - "X-API-Key", - "X-Virtual-Key" - }; - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Program.Caching.cs b/Services/ConduitLLM.Gateway/Program.Caching.cs index 9d9dff2fb..966ac248c 100644 --- a/Services/ConduitLLM.Gateway/Program.Caching.cs +++ b/Services/ConduitLLM.Gateway/Program.Caching.cs @@ -5,6 +5,7 @@ using StackExchange.Redis; using MassTransit; using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Services; public partial class Program { @@ -20,22 +21,7 @@ public static void ConfigureCachingServices(WebApplicationBuilder builder) // Virtual Key service registration will be done after Redis configuration // Configure Redis connection for all Redis-dependent services - // Check for REDIS_URL first, then fall back to CONDUIT_REDIS_CONNECTION_STRING - var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); - var redisConnectionString = Environment.GetEnvironmentVariable("CONDUIT_REDIS_CONNECTION_STRING"); - - if (!string.IsNullOrEmpty(redisUrl)) - { - try - { - redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ParseRedisUrl(redisUrl); - } - catch - { - // Failed to parse REDIS_URL, will use legacy connection string if available - // Validation will be logged during startup after logger is available - } - } + var redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ResolveConnectionString(); // Configure CacheOptions with the parsed Redis connection string // This ensures SignalRAcknowledgmentService and other services can access it @@ -52,20 +38,15 @@ public static void ConfigureCachingServices(WebApplicationBuilder builder) // Configure Redis connection multiplexer FIRST (shared across all Redis services) if (!string.IsNullOrEmpty(redisConnectionString)) { - Console.WriteLine($"[Conduit] Redis connection string configured: {redisConnectionString}"); - // Register Redis connection factory for proper connection pooling builder.Services.AddSingleton(); // Use Redis-cached Virtual Key service for high-performance validation builder.Services.AddSingleton(sp => { - Console.WriteLine("[Conduit] Creating Redis connection during service registration..."); var factory = sp.GetRequiredService(); var connectionTask = factory.GetConnectionAsync(redisConnectionString); - Console.WriteLine("[Conduit] Waiting for Redis connection to complete..."); var connection = connectionTask.GetAwaiter().GetResult(); - Console.WriteLine("[Conduit] Redis connection established successfully"); return connection; }); @@ -77,13 +58,11 @@ public static void ConfigureCachingServices(WebApplicationBuilder builder) options.Configuration = redisConnectionString; options.InstanceName = "conduit-tasks:"; }); - Console.WriteLine("[Conduit] Configured Redis distributed cache for async task storage"); } else { // Fall back to in-memory distributed cache builder.Services.AddDistributedMemoryCache(); - Console.WriteLine("[Conduit] Using in-memory distributed cache for async task storage (development mode)"); } // Register Virtual Key service with optional Redis caching @@ -92,16 +71,20 @@ public static void ConfigureCachingServices(WebApplicationBuilder builder) // IConnectionMultiplexer and RedisConnectionFactory are already registered above builder.Services.AddSingleton(); - + + // Register distributed lock service - prefer PostgreSQL for better consistency + // PostgreSQL advisory locks are more reliable for cache warming coordination + builder.Services.AddSingleton(); + + // Register cache stampede prevention service (must be registered before caches that depend on it) + builder.Services.AddSingleton(); + // Register additional Redis cache services builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - - // Register distributed lock service - prefer PostgreSQL for better consistency - // PostgreSQL advisory locks are more reliable for cache warming coordination - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Register CachedApiVirtualKeyService with event publishing dependency builder.Services.AddScoped(serviceProvider => @@ -115,19 +98,23 @@ public static void ConfigureCachingServices(WebApplicationBuilder builder) return new CachedApiVirtualKeyService(virtualKeyRepository, spendHistoryRepository, groupRepository, cache, publishEndpoint, logger); }); - - Console.WriteLine("[Conduit] Using Redis-cached services (high-performance mode) with PostgreSQL distributed locking"); - Console.WriteLine("[Conduit] Enabled caches: VirtualKey, Provider, GlobalSetting, ModelCost, IpFilter"); } else { // Fall back to direct database Virtual Key service - builder.Services.AddScoped(); - + builder.Services.AddScoped(sp => + { + var virtualKeyRepository = sp.GetRequiredService(); + var groupRepository = sp.GetRequiredService(); + var spendHistoryRepository = sp.GetRequiredService(); + var publishEndpoint = sp.GetService(); // Optional + var logger = sp.GetRequiredService>(); + return new ConduitLLM.Gateway.Services.DirectApiVirtualKeyService( + virtualKeyRepository, groupRepository, spendHistoryRepository, publishEndpoint, logger); + }); + // Register PostgreSQL distributed lock service (works even without Redis) builder.Services.AddSingleton(); - - Console.WriteLine("[Conduit] Using direct database Virtual Key validation (fallback mode) with PostgreSQL distributed locking"); } // Register Webhook Delivery Tracker for deduplication and statistics @@ -148,8 +135,6 @@ public static void ConfigureCachingServices(WebApplicationBuilder builder) return new ConduitLLM.Core.Services.CachedWebhookDeliveryTracker(redisTracker, memoryCache, logger); }); - - Console.WriteLine("[Conduit] Webhook delivery tracking configured with Redis backend and in-memory cache"); } else { @@ -158,8 +143,8 @@ public static void ConfigureCachingServices(WebApplicationBuilder builder) { var logger = sp.GetRequiredService>(); logger.LogWarning("No Redis connection configured. Webhook delivery tracking and deduplication will not be available."); - // Return a simple no-op implementation - return new ConduitLLM.Gateway.Services.NoOpWebhookDeliveryTracker(); + var noOpLogger = sp.GetRequiredService>(); + return new ConduitLLM.Gateway.Services.NoOpWebhookDeliveryTracker(noOpLogger); }); } } diff --git a/Services/ConduitLLM.Gateway/Program.Configuration.cs b/Services/ConduitLLM.Gateway/Program.Configuration.cs index 03b9c5d8c..163518f7d 100644 --- a/Services/ConduitLLM.Gateway/Program.Configuration.cs +++ b/Services/ConduitLLM.Gateway/Program.Configuration.cs @@ -17,11 +17,7 @@ public static void ConfigureBasicSettings(WebApplicationBuilder builder) if (skipDatabaseInit) { - Console.WriteLine("[Conduit] WARNING: Skipping database initialization. Ensure database schema is up to date."); - } - else - { - Console.WriteLine("[Conduit] Database will be initialized automatically."); + Console.Error.WriteLine("[Conduit] WARNING: Skipping database initialization. Ensure database schema is up to date."); } // Configure JSON options for snake_case serialization (OpenAI compatibility) @@ -40,6 +36,5 @@ public static void ConfigureBasicSettings(WebApplicationBuilder builder) .Bind(builder.Configuration.GetSection("Conduit")) .ValidateDataAnnotations(); // Add validation if using DataAnnotations in settings classes - // Database settings loading removed - provider configuration is now entirely database-driven } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Program.CoreServices.cs b/Services/ConduitLLM.Gateway/Program.CoreServices.cs index 721a8f36d..6968c7c08 100644 --- a/Services/ConduitLLM.Gateway/Program.CoreServices.cs +++ b/Services/ConduitLLM.Gateway/Program.CoreServices.cs @@ -1,4 +1,3 @@ -using System.Linq; using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Services; @@ -6,625 +5,147 @@ using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Services; -using ConduitLLM.Gateway.Security; using ConduitLLM.Gateway.Extensions; using ConduitLLM.Gateway.Services; using ConduitLLM.Providers.Extensions; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Hosting; -using Polly; -using Polly.Extensions.Http; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; -using MassTransit; -using Microsoft.AspNetCore.SignalR; public partial class Program { public static void ConfigureCoreServices(WebApplicationBuilder builder) { + // ========== Core Infrastructure ========== + // Add leader election service for distributed background service coordination builder.Services.AddLeaderElection(); - Console.WriteLine("[Conduit] Leader election service configured for background service coordination"); - - // Global settings cache service - loads settings at startup and provides fast access - builder.Services.AddSingleton(); - builder.Services.AddHostedService(provider => provider.GetRequiredService() as GlobalSettingsCacheService - ?? throw new InvalidOperationException("GlobalSettingsCacheService must be registered as singleton")); - Console.WriteLine("[Conduit] Global settings cache service configured"); - - // Rate Limiter registration - builder.Services.AddRateLimiter(options => - { - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - options.AddPolicy("VirtualKeyPolicy", context => - { - // Use the actual partition provider from the policy instance - var policy = context.RequestServices.GetRequiredService(); - return policy.GetPartition(context); - }); - }); - builder.Services.AddScoped(); - - // Model costs tracking service - builder.Services.AddScoped(); - - // Ephemeral key service for direct browser-to-API authentication (used for all direct access including SignalR) - builder.Services.AddScoped(); - - builder.Services.AddScoped(); - - // Tool cost calculation service for provider tool billing - builder.Services.AddScoped(); - - // Parameter validation service for minimal, provider-agnostic validation - builder.Services.AddScoped(); - // Virtual key service (Configuration layer - used by RealtimeUsageTracker) - builder.Services.AddScoped(); - - // Billing audit service for comprehensive billing event tracking - with leader election - builder.Services.AddSingleton(); - builder.Services.AddLeaderElectedHostedService( - provider => { - try - { - Console.WriteLine("[Leader Election] Resolving BillingAuditService..."); - var service = provider.GetRequiredService() as ConduitLLM.Configuration.Services.BillingAuditService - ?? throw new InvalidOperationException("BillingAuditService must implement IHostedService"); - Console.WriteLine("[Leader Election] โœ“ Successfully resolved BillingAuditService"); - return service; - } - catch (Exception ex) - { - Console.WriteLine($"[Leader Election] โœ— FAILED to resolve BillingAuditService: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[Leader Election] Stack trace: {ex.StackTrace}"); - throw; - } - }, - "BillingAuditService"); - - // Pricing rules engine services for flexible rules-based pricing - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Cached pricing rules service for parsed configuration caching - builder.Services.AddSingleton(); - Console.WriteLine("[Conduit] Pricing rules engine services registered"); - - // Pricing audit service for rules-based pricing evaluation tracking - with leader election - builder.Services.AddSingleton(); - builder.Services.AddLeaderElectedHostedService( - provider => { - try - { - Console.WriteLine("[Leader Election] Resolving PricingAuditService..."); - var service = provider.GetRequiredService() as ConduitLLM.Configuration.Services.PricingAuditService - ?? throw new InvalidOperationException("PricingAuditService must implement IHostedService"); - Console.WriteLine("[Leader Election] โœ“ Successfully resolved PricingAuditService"); - return service; - } - catch (Exception ex) - { - Console.WriteLine($"[Leader Election] โœ— FAILED to resolve PricingAuditService: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[Leader Election] Stack trace: {ex.StackTrace}"); - throw; - } - }, - "PricingAuditService"); + // Shared application services (GlobalSettingsCache, ProviderService, + // ModelProviderMapping+decorator, ProviderMetadataRegistry) + builder.Services.AddSharedApplicationServices(); - // Provider error tracking service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + // ========== Caching Infrastructure ========== builder.Services.AddMemoryCache(); - - // Add cache infrastructure with distributed statistics collection builder.Services.AddCacheInfrastructure(builder.Configuration); - // Configure OpenTelemetry with metrics and tracing - var otlpEndpoint = builder.Configuration["Telemetry:OtlpEndpoint"] ?? "http://localhost:4317"; - var tracingEnabled = builder.Configuration.GetValue("Telemetry:TracingEnabled", true); - - var otelBuilder = builder.Services.AddOpenTelemetry() - .WithMetrics(meterProviderBuilder => - { - meterProviderBuilder - .SetResourceBuilder(OpenTelemetry.Resources.ResourceBuilder.CreateDefault() - .AddService(serviceName: "ConduitLLM.Gateway", serviceVersion: "1.0.0")) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddMeter("ConduitLLM.SignalR") // Add SignalR metrics - .AddMeter("ConduitLLM.MediaGeneration") // Add Media Generation metrics - .AddPrometheusExporter(); - }); - - // Add distributed tracing when enabled - if (tracingEnabled) - { - otelBuilder.WithTracing(tracerProviderBuilder => - { - tracerProviderBuilder - .SetResourceBuilder(ResourceBuilder.CreateDefault() - .AddService(serviceName: "ConduitLLM.Gateway", serviceVersion: "1.0.0")) - .AddAspNetCoreInstrumentation(options => - { - // Filter out health check endpoints to reduce noise - options.Filter = httpContext => - !httpContext.Request.Path.StartsWithSegments("/health") && - !httpContext.Request.Path.StartsWithSegments("/metrics"); - }) - .AddHttpClientInstrumentation() - .AddSqlClientInstrumentation(options => - { - options.SetDbStatementForText = true; - options.RecordException = true; - }) - .AddRedisInstrumentation() - .AddSource("ConduitLLM.SignalR") - .AddSource("ConduitLLM.MediaGeneration") - .AddOtlpExporter(options => - { - options.Endpoint = new Uri(otlpEndpoint); - }); - }); - Console.WriteLine($"[Conduit] OpenTelemetry tracing enabled - exporting to {otlpEndpoint}"); - } - else - { - Console.WriteLine("[Conduit] OpenTelemetry tracing disabled (set Telemetry:TracingEnabled=true to enable)"); - } - - // Distributed monitoring services are registered in HealthMonitoringExtensions - // Legacy SignalRMetricsService registration removed - now using DistributedSignalRMetricsService + // ========== Correlation Context ========== - // Register new SignalR reliability services - builder.Services.AddSingleton(); - builder.Services.AddHostedService(provider => - (ConduitLLM.Gateway.Services.SignalRAcknowledgmentService)provider.GetRequiredService()); + builder.Services.AddCorrelationContext(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(provider => - (ConduitLLM.Gateway.Services.SignalRMessageQueueService)provider.GetRequiredService()); + // ========== Observability ========== - builder.Services.AddSingleton(); - builder.Services.AddHostedService(provider => - (ConduitLLM.Gateway.Services.SignalRConnectionMonitor)provider.GetRequiredService()); + builder.Services.AddObservabilityServices(builder.Configuration); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(provider => - (ConduitLLM.Gateway.Services.SignalRMessageBatcher)provider.GetRequiredService()); + // ========== SignalR Reliability ========== - // Register SignalR OpenTelemetry metrics - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); + builder.Services.AddSignalRReliabilityServices(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); + // ========== Database Services ========== - // 2. Register DbContext Factory (using connection string from environment variables) - var connectionStringManager = new ConduitLLM.Core.Data.ConnectionStringManager(); - // Pass "CoreAPI" to get Gateway API-specific connection pool settings - var (dbProvider, dbConnectionString) = connectionStringManager.GetProviderAndConnectionString("CoreAPI", msg => Console.WriteLine(msg)); + builder.Services.AddDatabaseServices(builder.Configuration); - // Log the connection pool settings for verification - if (dbProvider == "postgres" && dbConnectionString.Contains("MaxPoolSize")) - { - Console.WriteLine($"[Conduit] Gateway API database connection pool configured:"); - var match = System.Text.RegularExpressions.Regex.Match(dbConnectionString, @"MinPoolSize=(\d+);MaxPoolSize=(\d+)"); - if (match.Success) - { - Console.WriteLine($"[Conduit] Min Pool Size: {match.Groups[1].Value}"); - Console.WriteLine($"[Conduit] Max Pool Size: {match.Groups[2].Value}"); - } - } - - // Only PostgreSQL is supported - if (dbProvider != "postgres") - { - throw new InvalidOperationException($"Only PostgreSQL is supported. Invalid provider: {dbProvider}"); - } + // ========== Security ========== - builder.Services.AddDbContextFactory(options => - { - options.UseNpgsql(dbConnectionString); - }); - - // Also add scoped registration from factory for services that need direct injection - // Note: This creates contexts from the factory on demand - builder.Services.AddScoped(provider => - { - var factory = provider.GetService>(); - if (factory == null) - { - throw new InvalidOperationException("IDbContextFactory is not registered"); - } - return factory.CreateDbContext(); - }); - - // Authentication and authorization are configured later with policies - - // Add Gateway API Security services builder.Services.AddCoreApiSecurity(builder.Configuration); - // Add all the service registrations BEFORE calling builder.Build() - // Register HttpClientFactory - REQUIRED for LLMClientFactory - builder.Services.AddHttpClient(); + // ========== HTTP Infrastructure ========== - // Add standard LLM provider HTTP clients with timeout/retry policies + builder.Services.AddHttpClient(); builder.Services.AddLLMProviderHttpClients(); - - // Add video generation HTTP clients without timeout for long-running operations builder.Services.AddVideoGenerationHttpClients(); + builder.Services.AddHttpClientServices(builder.Configuration); // Register operation timeout provider for operation-aware timeout policies - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); - // Add dependencies needed for the Conduit service - // Use DatabaseAwareLLMClientFactory to get provider credentials from database - builder.Services.AddScoped(); + // ========== Provider Services ========== - // Add Provider Registry - single source of truth for provider metadata - builder.Services.AddSingleton(); - Console.WriteLine("[ConduitLLM.Gateway] Provider Registry registered - centralized provider metadata management enabled"); + // Register LLM client factory and provider services from shared extension + builder.Services.AddProviderServices(); + + // Note: ProviderMetadataRegistry registered via AddSharedApplicationServices() above + + // Provider error tracking service + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Add performance metrics service - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // ========== Billing & Pricing ========== + + builder.Services.AddBillingAndPricingServices(); - // Image generation metrics service removed - not needed + // ========== Token Management ========== + + // Parameter validation service for minimal, provider-agnostic validation + builder.Services.AddScoped(); // Register token counter service for context management - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // ========== Repositories ========== - // Register all repositories using the extension method builder.Services.AddRepositories(); - // Register services - // Register model provider mapping service with caching decorator pattern - builder.Services.AddScoped(); // Inner service - builder.Services.AddScoped(provider => - { - var innerService = provider.GetRequiredService(); - var cacheManager = provider.GetRequiredService(); - var logger = provider.GetRequiredService>(); - return new ConduitLLM.Core.Services.CachedModelProviderMappingService(innerService, cacheManager, logger); - }); - Console.WriteLine("[Conduit] Model provider mapping service registered with caching - reduces database queries by 80-95%"); + // ========== Model Services ========== - builder.Services.AddScoped(); - builder.Services.AddScoped(); + // Note: ModelProviderMappingService+decorator and ProviderService registered via AddSharedApplicationServices() above // Register System Notification Service - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Register Model Metadata Service builder.Services.AddSingleton(); - // Register TaskHub Service for ITaskHub interface - builder.Services.AddSingleton(); - - // Register Batch Operation Services - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - - // Register Batch Operation Idempotency Service (Redis-based) - builder.Services.AddSingleton(); - - // Register batch operations - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Register spend update batch operation - builder.Services.AddScoped(); - - // Register Webhook Delivery Service - builder.Services.AddSingleton(); - - // Register Distributed Spend Notification Service (Redis-based for multi-instance consistency) - with leader election - Console.WriteLine("[Service Registration] Registering DistributedSpendNotificationService..."); - // Register as singleton via interface using two-parameter syntax to avoid auto-discovery - builder.Services.AddSingleton(); - Console.WriteLine("[Service Registration] Adding leader-elected hosted service for DistributedSpendNotificationService..."); - builder.Services.AddLeaderElectedHostedService( - sp => { - try - { - Console.WriteLine("[Leader Election] Resolving DistributedSpendNotificationService..."); - var service = sp.GetRequiredService() as ConduitLLM.Gateway.Services.SpendNotification.DistributedSpendNotificationService - ?? throw new InvalidOperationException("DistributedSpendNotificationService must implement IHostedService"); - Console.WriteLine("[Leader Election] โœ“ Successfully resolved DistributedSpendNotificationService"); - return service; - } - catch (Exception ex) - { - Console.WriteLine($"[Leader Election] โœ— FAILED to resolve DistributedSpendNotificationService: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[Leader Election] Stack trace: {ex.StackTrace}"); - throw; - } - }, - "SpendNotificationService"); - - // Register Webhook Metrics Service (Redis-based when available) - builder.Services.AddSingleton(sp => - { - var redis = sp.GetService(); - - if (redis != null) - { - var logger = sp.GetRequiredService>(); - return new ConduitLLM.Core.Services.RedisWebhookMetricsService(redis, logger); - } - - // Return null when Redis is not available - the notification service will handle fallback - return null!; - }); - - // Register Webhook Connection Tracker (Redis-based when available) - builder.Services.AddSingleton(sp => - { - var redis = sp.GetService(); - - if (redis != null) - { - var logger = sp.GetRequiredService>(); - return new ConduitLLM.Core.Services.RedisWebhookConnectionTracker(redis, logger); - } - else - { - // Fall back to in-memory tracker - var logger = sp.GetRequiredService>(); - return new ConduitLLM.Core.Services.InMemoryWebhookConnectionTracker(logger); - } - }); - - // Register Webhook Delivery Notification Service - with leader election - Console.WriteLine("[Service Registration] Registering WebhookDeliveryNotificationService as singleton..."); - // Use factory to prevent auto-discovery by ASP.NET Core - builder.Services.AddSingleton(sp => - { - var hubContext = sp.GetRequiredService>(); - var serviceProvider = sp; - var logger = sp.GetRequiredService>(); - return new ConduitLLM.Gateway.Services.WebhookDeliveryNotificationService(hubContext, serviceProvider, logger); - }); - Console.WriteLine("[Service Registration] Adding leader-elected hosted service for WebhookDeliveryNotificationService..."); - builder.Services.AddLeaderElectedHostedService( - sp => { - try - { - Console.WriteLine("[Leader Election] Resolving WebhookDeliveryNotificationService..."); - var service = (ConduitLLM.Gateway.Services.WebhookDeliveryNotificationService)sp.GetRequiredService(); - Console.WriteLine("[Leader Election] โœ“ Successfully resolved WebhookDeliveryNotificationService"); - return service; - } - catch (Exception ex) - { - Console.WriteLine($"[Leader Election] โœ— FAILED to resolve WebhookDeliveryNotificationService: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[Leader Election] Stack trace: {ex.StackTrace}"); - throw; - } - }, - "WebhookDeliveryNotificationService"); - - // Model Capability Service is registered via ServiceCollectionExtensions - - // Provider Discovery Service is only used in Admin API for dynamic model discovery - // Gateway API relies on configured model mappings only - - // Register Video Generation Service with explicit dependencies - builder.Services.AddScoped(sp => - { - var clientFactory = sp.GetRequiredService(); - var capabilityService = sp.GetRequiredService(); - var costService = sp.GetRequiredService(); - var virtualKeyService = sp.GetRequiredService(); - var mediaStorage = sp.GetRequiredService(); - var taskService = sp.GetRequiredService(); - var logger = sp.GetRequiredService>(); - var modelMappingService = sp.GetRequiredService(); - var publishEndpoint = sp.GetService(); // Optional - var taskRegistry = sp.GetService(); // Optional - - return new VideoGenerationService( - clientFactory, - capabilityService, - costService, - virtualKeyService, - mediaStorage, - taskService, - logger, - modelMappingService, - publishEndpoint, - taskRegistry); - }); + // ========== Audit Services ========== - // Configure Video Generation Retry Settings - builder.Services.Configure(options => - { - options.MaxRetries = builder.Configuration.GetValue("VideoGeneration:MaxRetries", 3); - options.BaseDelaySeconds = builder.Configuration.GetValue("VideoGeneration:BaseDelaySeconds", 30); - options.MaxDelaySeconds = builder.Configuration.GetValue("VideoGeneration:MaxDelaySeconds", 3600); - options.EnableRetries = builder.Configuration.GetValue("VideoGeneration:EnableRetries", true); - options.RetryCheckIntervalSeconds = builder.Configuration.GetValue("VideoGeneration:RetryCheckIntervalSeconds", 30); - }); + builder.Services.AddAuditServices(); - // Register HTTP client for image downloads with retry policies - builder.Services.AddHttpClient("ImageDownload", client => - { - client.Timeout = TimeSpan.FromSeconds(60); // Timeout for large images - client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM-ImageDownloader/1.0"); - client.DefaultRequestHeaders.Add("Accept", "image/*"); - }) - .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler - { - PooledConnectionLifetime = TimeSpan.FromMinutes(5), - PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), - MaxConnectionsPerServer = 20, - EnableMultipleHttp2Connections = true, - MaxResponseHeadersLength = 64 * 1024, - ResponseDrainTimeout = TimeSpan.FromSeconds(10), - ConnectTimeout = TimeSpan.FromSeconds(10), - AutomaticDecompression = System.Net.DecompressionMethods.All, // Handle gzip/deflate - AllowAutoRedirect = true, // Handle redirects automatically - MaxAutomaticRedirections = 5 // Limit redirect chains - }) - .AddPolicyHandler(GetImageDownloadRetryPolicy()) - .AddPolicyHandler(Policy.TimeoutAsync(TimeSpan.FromSeconds(120))); // Overall timeout including retries - - // Register HTTP client for video downloads with retry policies - builder.Services.AddHttpClient("VideoDownload", client => - { - client.Timeout = TimeSpan.FromMinutes(10); // Much longer timeout for large videos - client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM-VideoDownloader/1.0"); - client.DefaultRequestHeaders.Add("Accept", "video/*"); - }) - .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler - { - PooledConnectionLifetime = TimeSpan.FromMinutes(10), - PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), - MaxConnectionsPerServer = 10, // Fewer connections for large transfers - EnableMultipleHttp2Connections = true, - MaxResponseHeadersLength = 64 * 1024, - ResponseDrainTimeout = TimeSpan.FromSeconds(30), - ConnectTimeout = TimeSpan.FromSeconds(30), - AutomaticDecompression = System.Net.DecompressionMethods.All, - AllowAutoRedirect = true, - MaxAutomaticRedirections = 5 - }) - .AddPolicyHandler(GetVideoDownloadRetryPolicy()) - .AddPolicyHandler(Policy.TimeoutAsync(TimeSpan.FromMinutes(15))); // Overall timeout including retries - - // Register Webhook Notification Service with optimized configuration for high throughput - builder.Services.AddTransient(); - builder.Services.AddHttpClient( - "WebhookClient", - client => - { - client.Timeout = TimeSpan.FromSeconds(10); // Reduced from 30s for better scalability - client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM/1.0"); - client.DefaultRequestHeaders.ConnectionClose = false; // Keep-alive for connection reuse - }) - .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler - { - PooledConnectionLifetime = TimeSpan.FromMinutes(5), // Refresh connections every 5 minutes - PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), // Close idle connections after 2 minutes - MaxConnectionsPerServer = 100, // Support 1000+ webhooks/min (17/sec avg, 100 concurrent) - EnableMultipleHttp2Connections = true, // Allow multiple HTTP/2 connections - MaxResponseHeadersLength = 64 * 1024, // 64KB for headers - ResponseDrainTimeout = TimeSpan.FromSeconds(5), // Drain response within 5 seconds - ConnectTimeout = TimeSpan.FromSeconds(5), // Connection timeout - KeepAlivePingTimeout = TimeSpan.FromSeconds(20), // HTTP/2 keep-alive ping timeout - KeepAlivePingDelay = TimeSpan.FromSeconds(30) // HTTP/2 keep-alive ping delay - }) - .AddPolicyHandler(GetWebhookRetryPolicy()) - .AddPolicyHandler(GetWebhookCircuitBreakerPolicy()) - .AddHttpMessageHandler(); - - // Register Webhook Circuit Breaker for preventing repeated failures - builder.Services.AddMemoryCache(); // Ensure memory cache is available - builder.Services.AddSingleton(sp => - { - var redis = sp.GetService(); - - if (redis != null) - { - // Use Redis-based distributed circuit breaker when available - var redisLogger = sp.GetRequiredService>(); - return new ConduitLLM.Core.Services.RedisWebhookCircuitBreaker( - redis, - redisLogger, - failureThreshold: 5, - openDuration: TimeSpan.FromMinutes(5), - halfOpenTestInterval: TimeSpan.FromSeconds(30)); - } - else - { - // Fall back to in-memory circuit breaker - var cache = sp.GetRequiredService(); - var logger = sp.GetRequiredService>(); - - return new ConduitLLM.Core.Services.WebhookCircuitBreaker( - cache, - logger, - failureThreshold: 5, - openDuration: TimeSpan.FromMinutes(5), - counterResetDuration: TimeSpan.FromMinutes(15)); - } - }); + // ========== Batch Operations ========== - // Register provider model list service - // OBSOLETE: External model discovery is no longer used. - // The ProviderModelsController now returns models from the local database. - // builder.Services.AddScoped(); + builder.Services.AddBatchOperationServices(); - // Model discovery providers have been migrated to sister classes + // ========== Webhook Services ========== - // Configure HttpClient for discovery providers - builder.Services.AddHttpClient("DiscoveryProviders", client => - { - client.Timeout = TimeSpan.FromSeconds(30); - client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM/1.0"); - }); + builder.Services.AddWebhookServices(builder.Configuration); + // ========== Async Task Services ========== - // Register async task service // Register cancellable task registry - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Always use hybrid database+cache task management - // This provides consistency across all deployments and proper event publishing - builder.Services.AddScoped(sp => + builder.Services.AddScoped(sp => { var repository = sp.GetRequiredService(); var cache = sp.GetRequiredService(); var publishEndpoint = sp.GetService(); // Optional - var logger = sp.GetRequiredService>(); - + var logger = sp.GetRequiredService>(); + return publishEndpoint != null - ? new ConduitLLM.Core.Services.HybridAsyncTaskService(repository, cache, publishEndpoint, logger) - : new ConduitLLM.Core.Services.HybridAsyncTaskService(repository, cache, logger); + ? new HybridAsyncTaskService(repository, cache, publishEndpoint, logger) + : new HybridAsyncTaskService(repository, cache, logger); }); - // Register Conduit service + // ========== Conduit Service ========== + builder.Services.AddScoped(); - // Register File Retrieval Service - builder.Services.AddScoped(); + // ========== Model Capability Services ========== - // Register Model Capability services (capability detection and caching) builder.Services.AddModelCapabilityServices(builder.Configuration); - // Register Function repositories - builder.Services.AddScoped(); - - // Register Function services - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + // ========== Function Services ========== - // Register Function Call Audit service with leader election - builder.Services.AddSingleton(); - builder.Services.AddLeaderElectedHostedService( - provider => provider.GetRequiredService() as ConduitLLM.Configuration.Services.FunctionCallAuditService - ?? throw new InvalidOperationException("FunctionCallAuditService must implement IHostedService"), - "FunctionCallAuditService"); + builder.Services.AddFunctionServices(); - // Register Agentic Function Calling services - builder.Services.AddScoped(); - builder.Services.AddScoped(); + // ========== Cache Services ========== // Register Batch Cache Invalidation service builder.Services.AddBatchCacheInvalidation(builder.Configuration); - + // Register Discovery Cache service for model discovery endpoint caching builder.Services.AddDiscoveryCache(builder.Configuration); @@ -633,109 +154,12 @@ public static void ConfigureCoreServices(WebApplicationBuilder builder) // Register Function Discovery Cache service for function tool definition caching builder.Services.AddFunctionDiscoveryCache(builder.Configuration); - Console.WriteLine("[Conduit] Function Discovery Cache registered - function tool definitions will be cached based on per-function TTL"); // Register Redis batch operations for optimized cache management - builder.Services.AddSingleton(); - + builder.Services.AddSingleton(); + // ========== Media Generation Services ========== - // Register Image Generation Retry Configuration - builder.Services.Configure( - builder.Configuration.GetSection("ConduitLLM:ImageGenerationRetry")); - - // Add background services for monitoring and cleanup (skip in test environment to prevent endless loops) - if (builder.Environment.EnvironmentName != "Test") - { - // Add database-based background service for image generation - // REMOVED: ImageGenerationDatabaseBackgroundService - Events are now processed by ImageGenerationOrchestrator consumer - - // DISABLED: VideoGenerationBackgroundService causes duplicate event publishing - // The VideoGenerationService already publishes VideoGenerationRequested events directly - // builder.Services.AddHostedService(); - - // Add background service for image generation metrics cleanup - // ImageGenerationMetricsCleanupService removed - metrics handled differently now - - // Register media generation metrics - builder.Services.AddSingleton(); - - // Register media generation orchestrators - builder.Services.AddScoped(); - builder.Services.AddScoped(); - } - - Console.WriteLine("[Conduit] Image generation configured with database-first architecture"); - Console.WriteLine("[Conduit] Image generation supports multi-instance deployment with lease-based task processing"); - Console.WriteLine("[Conduit] Image generation performance tracking and optimization enabled"); - } - - // Polly retry policy for image downloads with exponential backoff - static IAsyncPolicy GetImageDownloadRetryPolicy() - { - return HttpPolicyExtensions - .HandleTransientHttpError() // Handles HttpRequestException and 5XX, 408 status codes - .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - .WaitAndRetryAsync( - 3, // Retry up to 3 times - retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // Exponential backoff: 2, 4, 8 seconds - onRetry: (outcome, timespan, retryCount, context) => - { - // Log retry attempts (logger will be injected via DI in actual use) - var logger = context.Values.FirstOrDefault() as ILogger; - logger?.LogWarning("Image download retry {RetryCount} after {Delay}ms", retryCount, timespan.TotalMilliseconds); - }); - } - - // Polly retry policy for video downloads with longer exponential backoff - static IAsyncPolicy GetVideoDownloadRetryPolicy() - { - return HttpPolicyExtensions - .HandleTransientHttpError() - .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - .WaitAndRetryAsync( - 3, // Retry up to 3 times - retryAttempt => TimeSpan.FromSeconds(Math.Pow(3, retryAttempt)), // Longer backoff: 3, 9, 27 seconds - onRetry: (outcome, timespan, retryCount, context) => - { - var logger = context.Values.FirstOrDefault() as ILogger; - logger?.LogWarning("Video download retry {RetryCount} after {Delay}s", retryCount, timespan.TotalSeconds); - }); - } - - // Polly retry policy for webhook delivery - static IAsyncPolicy GetWebhookRetryPolicy() - { - return HttpPolicyExtensions - .HandleTransientHttpError() - .OrResult(msg => !msg.IsSuccessStatusCode && msg.StatusCode != System.Net.HttpStatusCode.BadRequest) - .WaitAndRetryAsync( - 3, - retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // Exponential backoff: 2s, 4s, 8s - onRetry: (outcome, timespan, retryCount, context) => - { - // Log retry attempts to console (logger not available in static context) - Console.WriteLine($"[Webhook Retry] Attempt {retryCount} after {timespan.TotalMilliseconds}ms. Status: {outcome.Result?.StatusCode.ToString() ?? "N/A"}"); - }); - } - - // Polly circuit breaker policy for webhook delivery - static IAsyncPolicy GetWebhookCircuitBreakerPolicy() - { - return HttpPolicyExtensions - .HandleTransientHttpError() - .CircuitBreakerAsync( - handledEventsAllowedBeforeBreaking: 5, - durationOfBreak: TimeSpan.FromMinutes(1), - onBreak: (result, duration) => - { - // Circuit breaker opened - this will be logged by the WebhookCircuitBreaker service - Console.WriteLine($"[Webhook Circuit Breaker] Opened for {duration.TotalSeconds} seconds"); - }, - onReset: () => - { - // Circuit breaker closed - Console.WriteLine("[Webhook Circuit Breaker] Reset"); - }); + builder.Services.AddMediaGenerationServices(builder.Configuration, builder.Environment); } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Program.Endpoints.cs b/Services/ConduitLLM.Gateway/Program.Endpoints.cs index 859ed5387..d29e2a9be 100644 --- a/Services/ConduitLLM.Gateway/Program.Endpoints.cs +++ b/Services/ConduitLLM.Gateway/Program.Endpoints.cs @@ -13,62 +13,48 @@ public static void ConfigureEndpoints(WebApplication app) // Customer-facing hubs require virtual key authentication app.MapHub("/hubs/video-generation") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR VideoGenerationHub registered at /hubs/video-generation (requires authentication)"); - + // Public video generation hub using task-scoped tokens (no virtual key required) app.MapHub("/hubs/public/video-generation"); - Console.WriteLine("[Gateway API] SignalR PublicVideoGenerationHub registered at /hubs/public/video-generation (token-based auth)"); app.MapHub("/hubs/image-generation") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR ImageGenerationHub registered at /hubs/image-generation (requires authentication)"); app.MapHub("/hubs/tasks") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR TaskHub registered at /hubs/tasks (requires authentication)"); app.MapHub("/hubs/notifications") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR SystemNotificationHub registered at /hubs/notifications (requires authentication)"); app.MapHub("/hubs/spend") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR SpendNotificationHub registered at /hubs/spend (requires authentication)"); app.MapHub("/hubs/webhooks") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR WebhookDeliveryHub registered at /hubs/webhooks (requires authentication)"); - // Admin-only hub for metrics dashboard app.MapHub("/hubs/metrics") .RequireAuthorization("AdminOnly"); - Console.WriteLine("[Gateway API] SignalR MetricsHub registered at /hubs/metrics (requires admin authentication)"); // Admin-only hub for health monitoring app.MapHub("/hubs/health-monitoring") .RequireAuthorization("AdminOnly"); - Console.WriteLine("[Gateway API] SignalR HealthMonitoringHub registered at /hubs/health-monitoring (requires admin authentication)"); // Admin-only hub for security monitoring app.MapHub("/hubs/security-monitoring") .RequireAuthorization("AdminOnly"); - Console.WriteLine("[Gateway API] SignalR SecurityMonitoringHub registered at /hubs/security-monitoring (requires admin authentication)"); // Virtual key management hub for real-time key management updates app.MapHub("/hubs/virtual-key-management") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR VirtualKeyManagementHub registered at /hubs/virtual-key-management (requires authentication)"); // Usage analytics hub for real-time analytics and monitoring app.MapHub("/hubs/usage-analytics") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR UsageAnalyticsHub registered at /hubs/usage-analytics (requires authentication)"); // Enhanced video generation hub with acknowledgment support app.MapHub("/hubs/enhanced-video-generation") .RequireAuthorization(); - Console.WriteLine("[Gateway API] SignalR EnhancedVideoGenerationHub registered at /hubs/enhanced-video-generation (requires authentication)"); // Map health check endpoints app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions @@ -92,8 +78,5 @@ public static void ConfigureEndpoints(WebApplication app) context => context.Request.Path == "/metrics" && (IpAddressHelper.IsPrivateNetworkRequest(context) || context.User.Identity?.IsAuthenticated == true)); - Console.WriteLine("[Gateway API] Prometheus metrics endpoint registered at /metrics (private network or authenticated)"); - - Console.WriteLine("[Gateway API] All API endpoints are now handled by controllers."); } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Program.Media.cs b/Services/ConduitLLM.Gateway/Program.Media.cs index f8c497454..a06e2bb8f 100644 --- a/Services/ConduitLLM.Gateway/Program.Media.cs +++ b/Services/ConduitLLM.Gateway/Program.Media.cs @@ -4,13 +4,9 @@ public partial class Program { public static void ConfigureMediaServices(WebApplicationBuilder builder) { - Console.WriteLine("[Conduit] ConfigureMediaServices - Using shared media configuration"); - // Use the shared media services configuration from ConduitLLM.Core // This provides IMediaStorageService for storing generated images/videos builder.Services.AddMediaServices(builder.Configuration); - // Note: Media lifecycle management (cleanup scheduler, retention policies) - // has been moved to Admin API. See ConduitLLM.Admin.Services.MediaCleanupSchedulerService } } diff --git a/Services/ConduitLLM.Gateway/Program.Messaging.cs b/Services/ConduitLLM.Gateway/Program.Messaging.cs index a07cf2f4e..7670b77cc 100644 --- a/Services/ConduitLLM.Gateway/Program.Messaging.cs +++ b/Services/ConduitLLM.Gateway/Program.Messaging.cs @@ -53,16 +53,15 @@ public static void ConfigureMessagingServices(WebApplicationBuilder builder) // Add async task cache invalidation handler x.AddConsumer(); x.AddConsumer(); + x.AddConsumer(); - // Navigation state event consumers removed - WebAdmin uses React Query instead of SignalR for model mapping updates - // Add cache invalidation consumers for runtime configuration updates x.AddConsumer(); x.AddConsumer(); // Add model mapping cache invalidation consumer - handles both model mapping cache // (CacheRegion.ModelMetadata) and discovery cache (CacheRegion.ModelDiscovery) - x.AddConsumer(); + x.AddConsumer(); // Add media lifecycle handler for tracking generated media x.AddConsumer(); @@ -76,9 +75,6 @@ public static void ConfigureMessagingServices(WebApplicationBuilder builder) // Add batch spend flush handler for admin operations and integration testing x.AddConsumer(); - // Note: Media lifecycle consumers moved to Admin API - // See ConduitLLM.Admin.Consumers.MediaRetentionConsumer and MediaDeletionConsumer - if (useRabbitMq) { x.UsingRabbitMq((context, cfg) => @@ -157,9 +153,6 @@ public static void ConfigureMessagingServices(WebApplicationBuilder builder) // This ensures VideoGenerationRequested events are routed to this endpoint e.ConfigureConsumeTopology = true; e.SetQuorumQueue(); - // Note: Removed x-single-active-consumer as it conflicts with partitioned processing - // Ordering is maintained through partition keys in the event messages - // Retry policy for transient failures e.UseMessageRetry(r => r.Incremental(3, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5))); @@ -213,26 +206,9 @@ public static void ConfigureMessagingServices(WebApplicationBuilder builder) e.ConfigureConsumer(context); }); - // Note: Media lifecycle endpoints moved to Admin API - // Retention checks, cleanup batches, and deletion are now handled by Admin API consumers - // Configure remaining endpoints with automatic topology cfg.ConfigureEndpoints(context); }); - - Console.WriteLine($"[Conduit] Event bus configured with RabbitMQ transport (multi-instance mode) - Host: {rabbitMqConfig.Host}:{rabbitMqConfig.Port}"); - Console.WriteLine("[Conduit] Event-driven architecture ENABLED - Services will publish events for:"); - Console.WriteLine(" - Virtual Key updates (cache invalidation across instances)"); - Console.WriteLine(" - Spend updates (ordered processing with race condition prevention)"); - Console.WriteLine(" - Provider credential changes (automatic capability refresh)"); - Console.WriteLine(" - Model capability discovery (shared across all instances)"); - Console.WriteLine(" - Model mapping changes (real-time WebAdmin updates via SignalR)"); - Console.WriteLine(" - Provider health changes (real-time WebAdmin updates via SignalR)"); - Console.WriteLine(" - Global settings changes (system-wide configuration updates)"); - Console.WriteLine(" - IP filter changes (security policy updates)"); - Console.WriteLine(" - Model cost changes (pricing updates)"); - Console.WriteLine(" - Video generation tasks (partitioned processing per virtual key)"); - Console.WriteLine(" - Image generation tasks (partitioned processing per virtual key)"); } else { @@ -269,13 +245,11 @@ public static void ConfigureMessagingServices(WebApplicationBuilder builder) // Configure endpoints with automatic topology cfg.ConfigureEndpoints(context); }); - - Console.WriteLine("[Conduit] Event bus configured with in-memory transport (single-instance mode)"); - Console.WriteLine("[Conduit] Event-driven architecture ENABLED - Services will publish events locally"); - Console.WriteLine("[Conduit] WARNING: For production multi-instance deployments, configure RabbitMQ:"); - Console.WriteLine(" - Set CONDUITLLM__RABBITMQ__HOST to your RabbitMQ host"); - Console.WriteLine(" - Set CONDUITLLM__RABBITMQ__USERNAME and CONDUITLLM__RABBITMQ__PASSWORD"); - Console.WriteLine(" - This enables cache consistency and ordered processing across instances"); + + Console.Error.WriteLine("[Conduit] WARNING: For production multi-instance deployments, configure RabbitMQ:"); + Console.Error.WriteLine(" - Set CONDUITLLM__RABBITMQ__HOST to your RabbitMQ host"); + Console.Error.WriteLine(" - Set CONDUITLLM__RABBITMQ__USERNAME and CONDUITLLM__RABBITMQ__PASSWORD"); + Console.Error.WriteLine(" - This enables cache consistency and ordered processing across instances"); } }); @@ -288,7 +262,6 @@ public static void ConfigureMessagingServices(WebApplicationBuilder builder) options.MaxBatchDelay = TimeSpan.FromMilliseconds(100); options.ConcurrentPublishers = 3; }); - Console.WriteLine("[Conduit] Batch webhook publisher configured for high-throughput delivery"); } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Program.Middleware.cs b/Services/ConduitLLM.Gateway/Program.Middleware.cs index b0a57f39b..1c9b46bf9 100644 --- a/Services/ConduitLLM.Gateway/Program.Middleware.cs +++ b/Services/ConduitLLM.Gateway/Program.Middleware.cs @@ -1,6 +1,7 @@ using ConduitLLM.Configuration.Data; using ConduitLLM.Core.Middleware; using ConduitLLM.Gateway.Middleware; +using ConduitLLM.Security.Middleware; using Scalar.AspNetCore; public partial class Program @@ -12,7 +13,7 @@ public static async Task ConfigureMiddleware(WebApplication app) { var logger = scope.ServiceProvider.GetRequiredService>(); ConduitLLM.Configuration.Extensions.DeprecationWarnings.LogEnvironmentVariableDeprecations(logger); - + // Validate Redis URL if provided var envRedisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); if (!string.IsNullOrEmpty(envRedisUrl)) @@ -24,11 +25,18 @@ public static async Task ConfigureMiddleware(WebApplication app) // Run database migrations await app.RunDatabaseMigrationAsync(); - Console.WriteLine("[Conduit] Database initialization phase completed, configuring middleware..."); + // Add correlation ID middleware (earliest โ€” establishes correlation context for all downstream middleware) + app.UseCorrelationId(); + + // Add request tracking middleware (after correlation ID, wraps entire request lifecycle) + app.UseGatewayRequestTracking(); // Enable CORS app.UseCors(); - Console.WriteLine("[Conduit] CORS configured"); + + // Add health endpoint authorization (early in pipeline, before authentication) + // This protects health endpoints from external access without valid key + app.UseHealthEndpointAuthorization(); // Enable Scalar API documentation in development if (app.Environment.IsDevelopment()) @@ -38,16 +46,13 @@ public static async Task ConfigureMiddleware(WebApplication app) // Map Scalar UI for interactive API documentation app.MapScalarApiReference(); - - Console.WriteLine("[Conduit] Scalar UI available at /scalar/v1"); } // Add security headers - app.UseCoreApiSecurityHeaders(); + app.UseGatewaySecurityHeaders(); // Add Redis availability check middleware (must be early in pipeline) app.UseRedisAvailability(); - Console.WriteLine("[Conduit] Redis circuit breaker middleware configured"); // Add authentication and authorization middleware app.UseAuthentication(); @@ -61,20 +66,20 @@ public static async Task ConfigureMiddleware(WebApplication app) // Add OpenAI error handling middleware to map exceptions to proper HTTP status codes app.UseOpenAIErrorHandling(); - Console.WriteLine("[Conduit] OpenAI error handling middleware configured"); // Add usage tracking middleware to capture LLM usage from responses app.UseUsageTracking(); - Console.WriteLine("[Conduit] Usage tracking middleware configured"); - - // Add HTTP metrics middleware for comprehensive request tracking - app.UseMiddleware(); - // Add security middleware (IP filtering, rate limiting, ban checks) + // Add security middleware (IP filtering, ban checks) app.UseCoreApiSecurity(); - // Enable rate limiting (now that Virtual Keys are authenticated) - app.UseRateLimiter(); + // Enforce per-virtual-key RPM/RPD rate limits (placed before metrics so rejected + // requests aren't counted as served). Reads VirtualKey.KeyHash + RateLimitRpm/Rpd + // stashed by VirtualKeyAuthenticationHandler; Backend-scheme requests pass through. + app.UseVirtualKeyRateLimiting(); + + // Add HTTP metrics middleware for comprehensive request tracking + app.UseMiddleware(); // Add timeout diagnostics middleware app.UseMiddleware(); @@ -87,6 +92,5 @@ public static async Task ConfigureMiddleware(WebApplication app) // Add controllers to the app app.MapControllers(); - Console.WriteLine("[Gateway API] Controllers registered"); } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Program.Monitoring.cs b/Services/ConduitLLM.Gateway/Program.Monitoring.cs index 207f1d011..4dd7daeca 100644 --- a/Services/ConduitLLM.Gateway/Program.Monitoring.cs +++ b/Services/ConduitLLM.Gateway/Program.Monitoring.cs @@ -18,23 +18,10 @@ public static void ConfigureMonitoringServices(WebApplicationBuilder builder) }); // Get Redis and RabbitMQ configuration for health checks - var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); - var redisConnectionString = Environment.GetEnvironmentVariable("CONDUIT_REDIS_CONNECTION_STRING"); - - if (!string.IsNullOrEmpty(redisUrl)) - { - try - { - redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ParseRedisUrl(redisUrl); - } - catch - { - // Failed to parse REDIS_URL, will use legacy connection string if available - } - } + var redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ResolveConnectionString(); var connectionStringManager = new ConduitLLM.Core.Data.ConnectionStringManager(); - var (dbProvider, dbConnectionString) = connectionStringManager.GetProviderAndConnectionString("CoreAPI", msg => Console.WriteLine(msg)); + var (dbProvider, dbConnectionString) = connectionStringManager.GetProviderAndConnectionString("CoreAPI"); var rabbitMqConfig = builder.Configuration.GetSection("ConduitLLM:RabbitMQ").Get() ?? new ConduitLLM.Configuration.RabbitMqConfiguration(); @@ -80,10 +67,6 @@ public static void ConfigureMonitoringServices(WebApplicationBuilder builder) tags: new[] { "leader_election", "background_services", "distributed" }); } - // Audio health checks removed per YAGNI principle - - // Add advanced health monitoring checks (includes SignalR and HTTP connection pool checks) - healthChecksBuilder.AddAdvancedHealthMonitoring(builder.Configuration); } // Add health monitoring services @@ -109,5 +92,15 @@ public static void ConfigureMonitoringServices(WebApplicationBuilder builder) return new ConduitLLM.Gateway.Services.BusinessMetricsService(scopeFactory, logger); }, "BusinessMetricsService"); + + // Add gateway operations metrics service for operation-level metrics + // Tracks LLM operations, batch operations, media operations, function executions, and routing decisions + builder.Services.AddLeaderElectedHostedService( + serviceProvider => + { + var logger = serviceProvider.GetRequiredService>(); + return new ConduitLLM.Gateway.Services.GatewayOperationsMetricsService(serviceProvider, logger); + }, + "GatewayOperationsMetricsService"); } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Program.Security.cs b/Services/ConduitLLM.Gateway/Program.Security.cs index c0b29e47f..fdc271904 100644 --- a/Services/ConduitLLM.Gateway/Program.Security.cs +++ b/Services/ConduitLLM.Gateway/Program.Security.cs @@ -1,4 +1,7 @@ +using Microsoft.AspNetCore.Authorization; + using ConduitLLM.Gateway.Authentication; +using ConduitLLM.Security.Authorization; public partial class Program { @@ -70,6 +73,15 @@ public static void ConfigureSecurityServices(WebApplicationBuilder builder) policy.AuthenticationSchemes.Add("Backend"); policy.RequireAuthenticatedUser(); }); + + // Add policy for health endpoint access - allows private network OR valid health key + options.AddPolicy("HealthMonitoring", policy => + { + policy.Requirements.Add(new HealthKeyRequirement()); + }); }); + + // Register the health key authorization handler + builder.Services.AddSingleton(); } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Program.SignalR.cs b/Services/ConduitLLM.Gateway/Program.SignalR.cs index bc657ef9d..cb23d41c8 100644 --- a/Services/ConduitLLM.Gateway/Program.SignalR.cs +++ b/Services/ConduitLLM.Gateway/Program.SignalR.cs @@ -12,20 +12,7 @@ public partial class Program public static void ConfigureSignalRServices(WebApplicationBuilder builder) { // Get Redis connection string from environment - var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); - var redisConnectionString = Environment.GetEnvironmentVariable("CONDUIT_REDIS_CONNECTION_STRING"); - - if (!string.IsNullOrEmpty(redisUrl)) - { - try - { - redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ParseRedisUrl(redisUrl); - } - catch - { - // Failed to parse REDIS_URL, will use legacy connection string if available - } - } + var redisConnectionString = ConduitLLM.Configuration.Utilities.RedisUrlParser.ResolveConnectionString(); // Register VirtualKeyHubFilter for SignalR authentication builder.Services.AddScoped(); @@ -49,7 +36,6 @@ public static void ConfigureSignalRServices(WebApplicationBuilder builder) // Register webhook metrics service (required for distributed tracking) builder.Services.AddSingleton(); - Console.WriteLine("[Conduit] SignalR configured with Redis-based distributed rate limiting"); } else { @@ -85,7 +71,6 @@ public static void ConfigureSignalRServices(WebApplicationBuilder builder) builder.Services.AddScoped(); // Register Metrics Aggregation Service and Hub - with leader election - Console.WriteLine("[Service Registration] Registering MetricsAggregationService as singleton..."); // Use factory to prevent auto-discovery by ASP.NET Core builder.Services.AddSingleton(sp => { @@ -94,91 +79,32 @@ public static void ConfigureSignalRServices(WebApplicationBuilder builder) var hubContext = sp.GetRequiredService>(); return new ConduitLLM.Gateway.Services.MetricsAggregationService(serviceProvider, logger, hubContext); }); - Console.WriteLine("[Service Registration] Adding leader-elected hosted service for MetricsAggregationService..."); builder.Services.AddLeaderElectedHostedService( sp => { - try - { - Console.WriteLine("[Leader Election] Resolving MetricsAggregationService..."); - var service = (ConduitLLM.Gateway.Services.MetricsAggregationService)sp.GetRequiredService(); - Console.WriteLine("[Leader Election] โœ“ Successfully resolved MetricsAggregationService"); - return service; - } - catch (Exception ex) - { - Console.WriteLine($"[Leader Election] โœ— FAILED to resolve MetricsAggregationService: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[Leader Election] Stack trace: {ex.StackTrace}"); - throw; - } + var service = (ConduitLLM.Gateway.Services.MetricsAggregationService)sp.GetRequiredService(); + return service; }, "MetricsAggregationService"); - // Register Business Metrics Background Service - with leader election - builder.Services.AddLeaderElectedHostedService("BusinessMetricsService"); - - // Add SignalR for real-time navigation state updates - var signalRBuilder = builder.Services.AddSignalR(options => - { - options.EnableDetailedErrors = builder.Environment.IsDevelopment(); - options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); - options.KeepAliveInterval = TimeSpan.FromSeconds(30); - options.MaximumReceiveMessageSize = 32 * 1024; // 32KB - options.StreamBufferCapacity = 10; - - // Add global filters - options.AddFilter(); - options.AddFilter(); - options.AddFilter(); - options.AddFilter(); - }); - - // Add MessagePack protocol support with LZ4 compression - // Enables both JSON (default) and MessagePack protocols for backward compatibility - var messagePackEnabled = Environment.GetEnvironmentVariable("SIGNALR_MESSAGEPACK_ENABLED")?.ToLowerInvariant() != "false"; - if (messagePackEnabled) - { - signalRBuilder.AddMessagePackProtocol(options => - { - // Configure MessagePack with security and compression - options.SerializerOptions = MessagePack.MessagePackSerializerOptions.Standard - .WithResolver(MessagePack.Resolvers.StandardResolver.Instance) - .WithSecurity(MessagePack.MessagePackSecurity.UntrustedData) // CVE-2020-5234 protection - .WithCompression(MessagePack.MessagePackCompression.Lz4BlockArray) // Use Lz4BlockArray for GC optimization - .WithCompressionMinLength(256); // Only compress messages > 256 bytes - }); - Console.WriteLine("[Conduit] SignalR configured with MessagePack protocol (LZ4 compression enabled)"); - Console.WriteLine("[Conduit] SignalR supports both JSON and MessagePack protocols for backward compatibility"); - } - else - { - Console.WriteLine("[Conduit] SignalR configured with JSON protocol only (MessagePack disabled)"); - } - - // Configure SignalR Redis backplane for horizontal scaling - // Use dedicated Redis connection string if available, otherwise fall back to main Redis connection + // Add SignalR with shared configuration (MessagePack, Redis backplane) var signalRRedisConnectionString = builder.Configuration.GetConnectionString("RedisSignalR") ?? redisConnectionString; - if (!string.IsNullOrEmpty(signalRRedisConnectionString)) - { - signalRBuilder.AddStackExchangeRedis(signalRRedisConnectionString, options => + builder.Services.AddConduitSignalR( + builder.Environment, + signalRRedisConnectionString, + redisChannelPrefix: "conduit_signalr:", + redisDatabase: 2, + serviceName: "Conduit", + configureHubOptions: options => { - options.Configuration.ChannelPrefix = new StackExchange.Redis.RedisChannel("conduit_signalr:", StackExchange.Redis.RedisChannel.PatternMode.Literal); - options.Configuration.DefaultDatabase = 2; // Separate database for SignalR + options.AddFilter(); + options.AddFilter(); + options.AddFilter(); + options.AddFilter(); }); - Console.WriteLine("[Conduit] SignalR configured with Redis backplane for horizontal scaling"); - } - else - { - Console.WriteLine("[Conduit] SignalR configured without Redis backplane (single-instance mode)"); - } - - // Navigation state notification service removed - WebAdmin uses React Query instead of SignalR for model mapping updates // Register settings refresh service for runtime configuration updates builder.Services.AddSingleton(); - // MediaLifecycleRepository removed - consolidated into MediaRecordRepository - // Migration: 20250827194408_ConsolidateMediaTables.cs - // Register video generation notification service builder.Services.AddSingleton(); @@ -194,8 +120,6 @@ public static void ConfigureSignalRServices(WebApplicationBuilder builder) // Register usage analytics notification service builder.Services.AddSingleton(); - // Model discovery notification services removed - capabilities now come from ModelProviderMapping - // Register billing alerting service for critical failure notifications builder.Services.AddSingleton(); @@ -242,22 +166,10 @@ public static void ConfigureSignalRServices(WebApplicationBuilder builder) return batchService; }); - Console.WriteLine("[Service Registration] Adding leader-elected hosted service for BatchSpendUpdateService..."); builder.Services.AddLeaderElectedHostedService( sp => { - try - { - Console.WriteLine("[Leader Election] Resolving BatchSpendUpdateService..."); - var service = (ConduitLLM.Configuration.Services.BatchSpendUpdateService)sp.GetRequiredService(); - Console.WriteLine("[Leader Election] โœ“ Successfully resolved BatchSpendUpdateService"); - return service; - } - catch (Exception ex) - { - Console.WriteLine($"[Leader Election] โœ— FAILED to resolve BatchSpendUpdateService: {ex.GetType().Name}: {ex.Message}"); - Console.WriteLine($"[Leader Election] Stack trace: {ex.StackTrace}"); - throw; - } + var service = (ConduitLLM.Configuration.Services.BatchSpendUpdateService)sp.GetRequiredService(); + return service; }, "BatchSpendUpdateService"); } diff --git a/Services/ConduitLLM.Gateway/Program.cs b/Services/ConduitLLM.Gateway/Program.cs index f6acb0530..b068ab445 100644 --- a/Services/ConduitLLM.Gateway/Program.cs +++ b/Services/ConduitLLM.Gateway/Program.cs @@ -25,7 +25,6 @@ // Configure endpoints Program.ConfigureEndpoints(app); -Console.WriteLine("[Conduit] All endpoints configured, starting application..."); app.Run(); // Make Program class accessible for testing diff --git a/Services/ConduitLLM.Gateway/Security/VirtualKeyRateLimitPolicy.cs b/Services/ConduitLLM.Gateway/Security/VirtualKeyRateLimitPolicy.cs deleted file mode 100644 index ded4aa2c8..000000000 --- a/Services/ConduitLLM.Gateway/Security/VirtualKeyRateLimitPolicy.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Threading.RateLimiting; - -using ConduitLLM.Core.Interfaces; - -using Microsoft.AspNetCore.RateLimiting; - -namespace ConduitLLM.Gateway.Security -{ - /// - /// Implements a rate limiting policy for virtual keys in the Conduit API. - /// - /// - /// - /// This policy applies rate limits to API requests based on the virtual key - /// used in the request. This helps prevent abuse and ensures fair resource - /// allocation among different clients. - /// - /// - /// The policy extracts the API key from either the "Authorization" header (as a Bearer token) - /// or from the "api-key" header. It identifies virtual keys by their "condt_" prefix. - /// - /// - /// Currently, this implementation does not apply rate limits to virtual keys, - /// returning a "no limiter" partition for all requests. This is because the - /// method must be synchronous, - /// while retrieving virtual key limits from the database requires async operations. - /// - /// - /// Future implementations could use a cached service to prefetch and maintain - /// rate limit configurations for virtual keys, enabling per-key rate limiting. - /// - /// - public class VirtualKeyRateLimitPolicy : IRateLimiterPolicy - { - private readonly IVirtualKeyService _virtualKeyService; - - /// - /// Initializes a new instance of the class. - /// - /// The service for managing virtual keys. - public VirtualKeyRateLimitPolicy(IVirtualKeyService virtualKeyService) - { - _virtualKeyService = virtualKeyService; - } - - /// - /// Gets the rate limit partition for the given HTTP context. - /// - /// The HTTP context of the current request. - /// A rate limit partition for the request. - /// - /// This method: - /// - /// Extracts the API key from the request headers - /// Identifies virtual keys by their "condt_" prefix - /// Currently returns a "no limiter" partition for all requests - /// - /// Note that this method must be synchronous as required by the ASP.NET Core rate limiting infrastructure, - /// which limits our ability to look up dynamic rate limits from the database. - /// - public RateLimitPartition GetPartition(HttpContext httpContext) - { - string? originalApiKey = null; - if (httpContext.Request.Headers.TryGetValue("Authorization", out var authHeader)) - { - var authValue = authHeader.FirstOrDefault(); - if (!string.IsNullOrEmpty(authValue) && authValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - originalApiKey = authValue.Substring("Bearer ".Length).Trim(); - } - } - else if (httpContext.Request.Headers.TryGetValue("api-key", out var apiKeyHeader)) - { - originalApiKey = apiKeyHeader.FirstOrDefault(); - } - - if (originalApiKey?.StartsWith("condt_", StringComparison.OrdinalIgnoreCase) == true) - { - // NOTE: This is a synchronous interface! If you need to do async DB work, you must cache limits elsewhere or prefetch. - // For now, just return no limiter for virtual keys (or a default global limiter if desired). - // See docs: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.ratelimiting.iratelimiterpolicy-1.getpartition?view=aspnetcore-8.0 - return RateLimitPartition.GetNoLimiter(httpContext); - } - return RateLimitPartition.GetNoLimiter(httpContext); - } - - /// - /// Gets a function that provides the partition key for the given HTTP context. - /// - /// - /// Null, as this policy uses the method instead. - /// - /// - /// This implementation returns null because it uses the method - /// to determine the rate limit partition directly, rather than using a partition key. - /// - public Func>? GetPartitionKeyProvider() => null; - - /// - /// Gets a function that is called when a request is rejected due to rate limiting. - /// - /// - /// When a request is rejected, this function: - /// - /// Sets the HTTP status code to 429 Too Many Requests - /// Returns a completed ValueTask - /// - /// - public Func? OnRejected => (context, token) => - { - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - return ValueTask.CompletedTask; - }; - } -} diff --git a/Services/ConduitLLM.Gateway/Services/AlertBatchingService.cs b/Services/ConduitLLM.Gateway/Services/AlertBatchingService.cs index af93b0d5c..520cf3244 100644 --- a/Services/ConduitLLM.Gateway/Services/AlertBatchingService.cs +++ b/Services/ConduitLLM.Gateway/Services/AlertBatchingService.cs @@ -1,11 +1,13 @@ using System.Collections.Concurrent; +using System.Threading.Channels; using Microsoft.Extensions.Options; using ConduitLLM.Configuration.DTOs.HealthMonitoring; namespace ConduitLLM.Gateway.Services { /// - /// Background service that batches alerts for efficient notification delivery + /// Background service that batches alerts for efficient notification delivery. + /// Uses a Channel-based work queue for proper error handling and graceful shutdown. /// public class AlertBatchingService : BackgroundService { @@ -14,7 +16,14 @@ public class AlertBatchingService : BackgroundService private readonly AlertNotificationOptions _options; private readonly ConcurrentQueue _alertQueue; private readonly SemaphoreSlim _batchSemaphore; - private Timer? _batchTimer; + private readonly Channel _workChannel; + + // Work item types for channel-based processing + private abstract record AlertWorkItem; + private record SendImmediateAlert(HealthAlert Alert) : AlertWorkItem; + private record QueueForBatch(HealthAlert Alert) : AlertWorkItem; + private record ProcessBatchNow : AlertWorkItem; + private record TimerTick : AlertWorkItem; public AlertBatchingService( IAlertNotificationService notificationService, @@ -26,6 +35,13 @@ public AlertBatchingService( _options = options.Value; _alertQueue = new ConcurrentQueue(); _batchSemaphore = new SemaphoreSlim(1, 1); + + // Unbounded channel - alerts should always be accepted + _workChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); } /// @@ -35,33 +51,118 @@ public void QueueAlert(HealthAlert alert) { if (!_options.EnableBatching) { - // If batching is disabled, send immediately - _ = Task.Run(async () => await _notificationService.SendAlertAsync(alert)); + // Signal to send immediately via the work channel + if (!_workChannel.Writer.TryWrite(new SendImmediateAlert(alert))) + { + _logger.LogWarning("Failed to queue immediate alert - channel may be closed"); + } return; } - _alertQueue.Enqueue(alert); - - // If queue is getting large, trigger immediate batch - if (_alertQueue.Count() >= _options.MaxBatchSize) + // Signal to queue for batch + if (!_workChannel.Writer.TryWrite(new QueueForBatch(alert))) { - _ = Task.Run(async () => await ProcessBatchAsync()); + _logger.LogWarning("Failed to queue alert for batching - channel may be closed"); } } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Alert batching service started"); + _logger.LogInformation( + "Alert batching service started โ€” batching {Enabled}, interval: {IntervalSeconds}s, max batch size: {MaxBatchSize}", + _options.EnableBatching ? "enabled" : "disabled", + _options.BatchIntervalSeconds, + _options.MaxBatchSize); + + // Start the batch timer task + var timerTask = RunBatchTimerAsync(stoppingToken); + + // Process work items from the channel + try + { + await foreach (var workItem in _workChannel.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await ProcessWorkItemAsync(workItem); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing alert work item of type {WorkItemType}", workItem.GetType().Name); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown + _logger.LogInformation("Alert batching service stopping - processing remaining items"); + } + + // Wait for timer to stop + try + { + await timerTask; + } + catch (OperationCanceledException) + { + // Expected during shutdown + } + } + + private async Task RunBatchTimerAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.BatchIntervalSeconds)); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + _workChannel.Writer.TryWrite(new TimerTick()); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown + } + } - // Set up batch timer - _batchTimer = new Timer( - async _ => await ProcessBatchAsync(), - null, - TimeSpan.FromSeconds(_options.BatchIntervalSeconds), - TimeSpan.FromSeconds(_options.BatchIntervalSeconds)); + private async Task ProcessWorkItemAsync(AlertWorkItem workItem) + { + switch (workItem) + { + case SendImmediateAlert immediate: + await SendImmediateAlertAsync(immediate.Alert); + break; - // Keep service running - await Task.Delay(Timeout.Infinite, stoppingToken); + case QueueForBatch queue: + _alertQueue.Enqueue(queue.Alert); + // Check if batch size threshold exceeded + if (_alertQueue.Count >= _options.MaxBatchSize) + { + await ProcessBatchAsync(); + } + break; + + case ProcessBatchNow: + case TimerTick: + await ProcessBatchAsync(); + break; + } + } + + private async Task SendImmediateAlertAsync(HealthAlert alert) + { + try + { + _logger.LogDebug("Sending immediate alert: {AlertType} [{Severity}] for {Component}", + alert.Type, alert.Severity, alert.Component); + await _notificationService.SendAlertAsync(alert); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send immediate alert: {AlertType} [{Severity}] for {Component}", + alert.Type, alert.Severity, alert.Component); + } } private async Task ProcessBatchAsync() @@ -82,18 +183,20 @@ private async Task ProcessBatchAsync() alerts.Add(alert); } - if (alerts.Count() > 0) + if (alerts.Any()) { - _logger.LogInformation("Processing batch of {Count} alerts", alerts.Count()); - + _logger.LogInformation("Processing batch of {Count} alerts (queue remaining: {QueueRemaining})", + alerts.Count, _alertQueue.Count); + try { await _notificationService.SendBatchAlertsAsync(alerts); + _logger.LogDebug("Successfully delivered batch of {Count} alerts", alerts.Count); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send alert batch"); - + _logger.LogError(ex, "Failed to send alert batch of {Count} alerts โ€” re-queuing", alerts.Count); + // Re-queue failed alerts foreach (var alert in alerts) { @@ -112,10 +215,10 @@ public override async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("Alert batching service stopping"); - // Stop the timer - _batchTimer?.Dispose(); + // Complete the channel to stop accepting new items + _workChannel.Writer.Complete(); - // Process any remaining alerts + // Process any remaining alerts in the queue await ProcessBatchAsync(); await base.StopAsync(cancellationToken); @@ -123,7 +226,6 @@ public override async Task StopAsync(CancellationToken cancellationToken) public override void Dispose() { - _batchTimer?.Dispose(); _batchSemaphore?.Dispose(); base.Dispose(); } diff --git a/Services/ConduitLLM.Gateway/Services/AlertManagementService.cs b/Services/ConduitLLM.Gateway/Services/AlertManagementService.cs index 009e50212..37b74b1cb 100644 --- a/Services/ConduitLLM.Gateway/Services/AlertManagementService.cs +++ b/Services/ConduitLLM.Gateway/Services/AlertManagementService.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using Microsoft.Extensions.Caching.Memory; using ConduitLLM.Configuration.DTOs.HealthMonitoring; +using ConduitLLM.Core.Extensions; using ConduitLLM.Gateway.Hubs; using Microsoft.AspNetCore.SignalR; @@ -143,7 +144,7 @@ public Task SaveAlertRuleAsync(AlertRule rule) _alertRules[rule.Id] = rule; SaveToCache(); - _logger.LogInformation("Alert rule {RuleId} saved: {RuleName}", rule.Id, rule.Name); + _logger.LogInformation("Alert rule {RuleId} saved: {RuleName}", rule.Id, LoggingSanitizer.S(rule.Name)); return Task.FromResult(rule); } @@ -229,7 +230,7 @@ public async Task TriggerAlertAsync(HealthAlert alert) // Check if alert should be suppressed if (await IsAlertSuppressedAsync(alert)) { - _logger.LogDebug("Alert suppressed: {AlertTitle}", alert.Title); + _logger.LogDebug("Alert suppressed: {AlertTitle}", LoggingSanitizer.S(alert.Title)); return; } diff --git a/Services/ConduitLLM.Gateway/Services/AlertNotificationService.cs b/Services/ConduitLLM.Gateway/Services/AlertNotificationService.cs index 5f67c13db..d4690a6b7 100644 --- a/Services/ConduitLLM.Gateway/Services/AlertNotificationService.cs +++ b/Services/ConduitLLM.Gateway/Services/AlertNotificationService.cs @@ -62,7 +62,7 @@ public async Task SendBatchAlertsAsync(IEnumerable alerts, Cancella { var filteredAlerts = alerts.Where(ShouldSendAlert).ToList(); - if (filteredAlerts.Count() == 0) + if (!filteredAlerts.Any()) { _logger.LogDebug("All alerts filtered out by severity threshold"); return; @@ -194,12 +194,12 @@ private async Task SendWebhookAsync(object payload, CancellationToken cancellati } } - var response = await httpClient.PostAsync(_options.Url, content, cancellationToken); - + using var response = await httpClient.PostAsync(_options.Url, content, cancellationToken); + if (!response.IsSuccessStatusCode) { var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError("Webhook failed with status {StatusCode}: {Response}", + _logger.LogError("Webhook failed with status {StatusCode}: {Response}", response.StatusCode, responseBody); throw new HttpRequestException($"Webhook failed with status {response.StatusCode}"); } @@ -297,7 +297,7 @@ private string FormatAlertEmail(HealthAlert alert) sb.AppendLine($"

Time: {alert.TriggeredAt:yyyy-MM-dd HH:mm:ss} UTC

"); sb.AppendLine($"

Message: {alert.Message}

"); - if (alert.SuggestedActions.Count() > 0) + if (alert.SuggestedActions.Any()) { sb.AppendLine("

Suggested Actions:

"); sb.AppendLine("
    "); @@ -308,7 +308,7 @@ private string FormatAlertEmail(HealthAlert alert) sb.AppendLine("
"); } - if (alert.Context.Count() > 0) + if (alert.Context.Any()) { sb.AppendLine("

Additional Context:

"); sb.AppendLine("
");
diff --git a/Services/ConduitLLM.Gateway/Services/ApiVirtualKeyService.cs b/Services/ConduitLLM.Gateway/Services/ApiVirtualKeyService.cs
deleted file mode 100644
index bebc682ac..000000000
--- a/Services/ConduitLLM.Gateway/Services/ApiVirtualKeyService.cs
+++ /dev/null
@@ -1,544 +0,0 @@
-using System.Text;
-using ConduitLLM.Core.Extensions;
-
-using ConduitLLM.Configuration.DTOs.VirtualKey;
-using ConduitLLM.Configuration.Entities;
-using ConduitLLM.Configuration.Enums;
-using ConduitLLM.Configuration.Interfaces;
-
-namespace ConduitLLM.Gateway.Services
-{
-    /// 
-    /// API-specific implementation of IVirtualKeyService that directly uses repositories
-    /// 
-    /// 
-    /// This provides a lightweight implementation of IVirtualKeyService for the API project,
-    /// without requiring dependencies on the WebAdmin project.
-    /// 
-    public class ApiVirtualKeyService : Core.Interfaces.IVirtualKeyService
-    {
-        private readonly IVirtualKeyRepository _virtualKeyRepository;
-        private readonly IVirtualKeyGroupRepository _groupRepository;
-        private readonly IVirtualKeySpendHistoryRepository _spendHistoryRepository;
-        private readonly ILogger _logger;
-
-        /// 
-        /// Initializes a new instance of the ApiVirtualKeyService
-        /// 
-        public ApiVirtualKeyService(
-            IVirtualKeyRepository virtualKeyRepository,
-            IVirtualKeyGroupRepository groupRepository,
-            IVirtualKeySpendHistoryRepository spendHistoryRepository,
-            ILogger logger)
-        {
-            _virtualKeyRepository = virtualKeyRepository ?? throw new ArgumentNullException(nameof(virtualKeyRepository));
-            _groupRepository = groupRepository ?? throw new ArgumentNullException(nameof(groupRepository));
-            _spendHistoryRepository = spendHistoryRepository ?? throw new ArgumentNullException(nameof(spendHistoryRepository));
-            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
-        }
-
-        /// 
-        public async Task GenerateVirtualKeyAsync(CreateVirtualKeyRequestDto request)
-        {
-            try
-            {
-                // Generate a new key with prefix
-                var keyValue = GenerateSecureKey();
-                var keyWithPrefix = $"condt_{keyValue}";
-                
-                // Hash the key for storage
-                var keyHash = HashKey(keyWithPrefix);
-                
-                // VirtualKeyGroupId is now required
-                var existingGroup = await _groupRepository.GetByIdAsync(request.VirtualKeyGroupId);
-                if (existingGroup == null)
-                {
-                    throw new InvalidOperationException($"Virtual key group {request.VirtualKeyGroupId} not found. Ensure the group exists before creating keys.");
-                }
-                var groupId = existingGroup.Id;
-
-                // Create the virtual key entity
-                var virtualKey = new VirtualKey
-                {
-                    KeyName = request.KeyName,
-                    KeyHash = keyHash,
-                    AllowedModels = request.AllowedModels,
-                    VirtualKeyGroupId = groupId, // Assign to group
-                    IsEnabled = true,
-                    ExpiresAt = request.ExpiresAt,
-                    Metadata = request.Metadata,
-                    RateLimitRpm = request.RateLimitRpm,
-                    RateLimitRpd = request.RateLimitRpd,
-                    CreatedAt = DateTime.UtcNow,
-                    UpdatedAt = DateTime.UtcNow
-                };
-                
-                // Save to database
-                var createdId = await _virtualKeyRepository.CreateAsync(virtualKey);
-                
-                if (createdId > 0)
-                {
-                    // Retrieve the created virtual key to get all populated fields
-                    var created = await _virtualKeyRepository.GetByIdAsync(createdId);
-                    if (created != null)
-                    {
-                        _logger.LogInformation("Created new virtual key: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(created.KeyName), created.Id);
-                        
-                        // Return the response with the actual key (only shown once)
-                        return new CreateVirtualKeyResponseDto
-                        {
-                            VirtualKey = keyWithPrefix,
-                            KeyInfo = MapToDto(created)
-                        };
-                    }
-                }
-                
-                throw new InvalidOperationException("Failed to create virtual key");
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                "Error generating virtual key");
-                throw;
-            }
-        }
-
-        /// 
-        public async Task GetVirtualKeyInfoAsync(int id)
-        {
-            try
-            {
-                var virtualKey = await _virtualKeyRepository.GetByIdAsync(id);
-                if (virtualKey == null)
-                {
-                    _logger.LogWarning("Virtual key with ID {KeyId} not found",
-                id);
-                    return null;
-                }
-                
-                return MapToDto(virtualKey);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                "Error retrieving virtual key info for ID {KeyId}",
-                id);
-                throw;
-            }
-        }
-
-        /// 
-        public async Task> ListVirtualKeysAsync()
-        {
-            try
-            {
-                var virtualKeys = await _virtualKeyRepository.GetAllAsync();
-                return [..virtualKeys.Select(MapToDto)];
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                "Error listing virtual keys");
-                throw;
-            }
-        }
-
-        /// 
-        public async Task UpdateVirtualKeyAsync(int id, UpdateVirtualKeyRequestDto request)
-        {
-            try
-            {
-                var virtualKey = await _virtualKeyRepository.GetByIdAsync(id);
-                if (virtualKey == null)
-                {
-                    _logger.LogWarning("Virtual key with ID {KeyId} not found for update",
-                id);
-                    return false;
-                }
-                
-                // Update fields only if provided (null means no change)
-                if (request.KeyName != null)
-                    virtualKey.KeyName = request.KeyName;
-                    
-                if (request.AllowedModels != null)
-                    virtualKey.AllowedModels = string.IsNullOrEmpty(request.AllowedModels) ? null : request.AllowedModels;
-                    
-                // Note: Budget changes are now handled at the group level, not the key level
-                    
-                if (request.IsEnabled.HasValue)
-                    virtualKey.IsEnabled = request.IsEnabled.Value;
-                    
-                if (request.ExpiresAt.HasValue)
-                    virtualKey.ExpiresAt = request.ExpiresAt.Value;
-                    
-                if (request.Metadata != null)
-                    virtualKey.Metadata = string.IsNullOrEmpty(request.Metadata) ? null : request.Metadata;
-                    
-                if (request.RateLimitRpm.HasValue)
-                    virtualKey.RateLimitRpm = request.RateLimitRpm.Value;
-                    
-                if (request.RateLimitRpd.HasValue)
-                    virtualKey.RateLimitRpd = request.RateLimitRpd.Value;
-                
-                virtualKey.UpdatedAt = DateTime.UtcNow;
-                
-                var success = await _virtualKeyRepository.UpdateAsync(virtualKey);
-                
-                if (success)
-                {
-                    _logger.LogInformation("Updated virtual key: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(virtualKey.KeyName), id);
-                }
-                
-                return success;
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                "Error updating virtual key with ID {KeyId}",
-                id);
-                return false;
-            }
-        }
-
-        /// 
-        public async Task DeleteVirtualKeyAsync(int id)
-        {
-            try
-            {
-                var virtualKey = await _virtualKeyRepository.GetByIdAsync(id);
-                if (virtualKey == null)
-                {
-                    _logger.LogWarning("Virtual key with ID {KeyId} not found for deletion",
-                id);
-                    return false;
-                }
-                
-                var success = await _virtualKeyRepository.DeleteAsync(id);
-                
-                if (success)
-                {
-                    _logger.LogInformation("Deleted virtual key: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(virtualKey.KeyName), id);
-                }
-                
-                return success;
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                "Error deleting virtual key with ID {KeyId}",
-                id);
-                return false;
-            }
-        }
-
-        /// 
-        public async Task ResetSpendAsync(int id)
-        {
-            var virtualKey = await _virtualKeyRepository.GetByIdAsync(id);
-            if (virtualKey == null) return false;
-
-            try
-            {
-                // Get the virtual key's group
-                var group = await _groupRepository.GetByIdAsync(virtualKey.VirtualKeyGroupId);
-                if (group == null)
-                {
-                    _logger.LogError("Virtual key {KeyId} has invalid group ID {GroupId}", id, virtualKey.VirtualKeyGroupId);
-                    return false;
-                }
-
-                // Record the spend history before resetting
-                if (group.LifetimeSpent > 0)
-                {
-                    var spendHistory = new VirtualKeySpendHistory
-                    {
-                        VirtualKeyId = virtualKey.Id,
-                        Amount = group.LifetimeSpent,
-                        Date = DateTime.UtcNow
-                    };
-                    await _spendHistoryRepository.CreateAsync(spendHistory);
-                }
-
-                // Reset the group's spent amount (add back what was spent)
-                if (group.LifetimeSpent > 0)
-                {
-                    await _groupRepository.AdjustBalanceAsync(
-                        group.Id,
-                        group.LifetimeSpent,
-                        $"Spend reset for virtual key #{virtualKey.Id}",
-                        "System",
-                        ReferenceType.System,
-                        virtualKey.Id.ToString());
-
-                    // Reset lifetime spent
-                    group.LifetimeSpent = 0;
-                    group.UpdatedAt = DateTime.UtcNow;
-                    await _groupRepository.UpdateAsync(group);
-                }
-
-                // Update the virtual key timestamp
-                virtualKey.UpdatedAt = DateTime.UtcNow;
-                return await _virtualKeyRepository.UpdateAsync(virtualKey);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                "Error resetting spend for virtual key with ID {KeyId}",
-                id);
-                return false;
-            }
-        }
-
-        /// 
-        public async Task ValidateVirtualKeyForAuthenticationAsync(string key, string? requestedModel = null)
-        {
-            if (string.IsNullOrEmpty(key))
-            {
-                _logger.LogWarning("Empty key provided for authentication validation");
-                return null;
-            }
-
-            // Hash the incoming key before looking it up
-            var keyHash = HashKey(key);
-            _logger.LogDebug("Validating key for authentication: {KeyPrefix}..., Hash: {Hash}", 
-                key.Length > 10 ? key.Substring(0, 10) : key, keyHash);
-            
-            var virtualKey = await _virtualKeyRepository.GetByKeyHashAsync(keyHash);
-            if (virtualKey == null)
-            {
-                _logger.LogWarning("No matching virtual key found for hash: {Hash}", keyHash);
-                return null;
-            }
-
-            // Check if key is enabled
-            if (!virtualKey.IsEnabled)
-            {
-                _logger.LogWarning("Virtual key is disabled: {KeyName} (ID: {KeyId})", 
-                    LoggingSanitizer.S(virtualKey.KeyName) ?? "Unknown", virtualKey.Id);
-                return null;
-            }
-
-            // Check expiration
-            if (virtualKey.ExpiresAt.HasValue && virtualKey.ExpiresAt.Value < DateTime.UtcNow)
-            {
-                _logger.LogWarning("Virtual key has expired: {KeyName} (ID: {KeyId}), expired at {ExpiryDate}",
-                    LoggingSanitizer.S(virtualKey.KeyName) ?? "Unknown", virtualKey.Id, virtualKey.ExpiresAt);
-                return null;
-            }
-
-            // Check if model is allowed (but skip balance check for authentication)
-            if (!string.IsNullOrEmpty(requestedModel) && !string.IsNullOrEmpty(virtualKey.AllowedModels))
-            {
-                bool isModelAllowed = IsModelAllowed(requestedModel, virtualKey.AllowedModels);
-                if (!isModelAllowed)
-                {
-                    _logger.LogWarning("Virtual key {KeyName} (ID: {KeyId}) attempted to access restricted model: {RequestedModel}",
-                        LoggingSanitizer.S(virtualKey.KeyName) ?? "Unknown", virtualKey.Id, 
-                        LoggingSanitizer.S(requestedModel));
-                    return null;
-                }
-            }
-
-            // Authentication validation passed
-            _logger.LogDebug("Virtual key authenticated successfully: {KeyName} (ID: {KeyId})",
-                LoggingSanitizer.S(virtualKey.KeyName) ?? "Unknown", virtualKey.Id);
-            return virtualKey;
-        }
-
-        /// 
-        public async Task ValidateVirtualKeyAsync(string key, string? requestedModel = null)
-        {
-            if (string.IsNullOrEmpty(key))
-            {
-                _logger.LogWarning("Empty key provided for validation");
-                return null;
-            }
-
-            // Hash the incoming key before looking it up
-            var keyHash = HashKey(key);
-            _logger.LogDebug("Validating key: {KeyPrefix}..., Hash: {Hash}", 
-                key.Length > 10 ? key.Substring(0, 10) : key, keyHash);
-            
-            var virtualKey = await _virtualKeyRepository.GetByKeyHashAsync(keyHash);
-            if (virtualKey == null)
-            {
-                _logger.LogWarning("No matching virtual key found for hash: {Hash}", keyHash);
-                return null;
-            }
-
-            // Check if key is enabled
-            if (!virtualKey.IsEnabled)
-            {
-                _logger.LogWarning("Virtual key is disabled: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id);
-                return null;
-            }
-
-            // Check expiration
-            if (virtualKey.ExpiresAt.HasValue && virtualKey.ExpiresAt.Value < DateTime.UtcNow)
-            {
-                _logger.LogWarning("Virtual key has expired: {KeyName} (ID: {KeyId}), expired at {ExpiryDate}",
-                    LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id, virtualKey.ExpiresAt);
-                return null;
-            }
-
-            // Check group balance
-            var group = await _groupRepository.GetByIdAsync(virtualKey.VirtualKeyGroupId);
-            if (group != null && group.Balance <= 0)
-            {
-                _logger.LogWarning("Virtual key group budget depleted: {KeyName} (ID: {KeyId}), group {GroupId} has balance {Balance}",
-                    LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id, group.Id, group.Balance);
-                return null;
-            }
-
-            // Check if model is allowed, if model restrictions are in place
-            if (!string.IsNullOrEmpty(requestedModel) && !string.IsNullOrEmpty(virtualKey.AllowedModels))
-            {
-                bool isModelAllowed = IsModelAllowed(requestedModel, virtualKey.AllowedModels);
-
-                if (!isModelAllowed)
-                {
-                    _logger.LogWarning("Virtual key {KeyName} (ID: {KeyId}) attempted to access restricted model: {RequestedModel}",
-                        LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id, LoggingSanitizer.S(requestedModel));
-                    return null;
-                }
-            }
-
-            // All validations passed
-            _logger.LogInformation("Validated virtual key successfully: {KeyName} (ID: {KeyId})",
-                LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id);
-            return virtualKey;
-        }
-
-        /// 
-        public async Task UpdateSpendAsync(int keyId, decimal cost)
-        {
-            if (cost <= 0) return true; // No cost to add, consider it successful
-
-            var virtualKey = await _virtualKeyRepository.GetByIdAsync(keyId);
-            if (virtualKey == null) return false;
-
-            try
-            {
-                // Get the key's group
-                var group = await _groupRepository.GetByIdAsync(virtualKey.VirtualKeyGroupId);
-                if (group == null)
-                {
-                    _logger.LogError("Virtual key {KeyId} has invalid group ID {GroupId}", keyId, virtualKey.VirtualKeyGroupId);
-                    return false;
-                }
-
-                // Update the group balance
-                var newBalance = await _groupRepository.AdjustBalanceAsync(
-                    group.Id,
-                    -cost,
-                    $"API usage by virtual key #{keyId}",
-                    "System",
-                    ReferenceType.VirtualKey,
-                    keyId.ToString());
-
-                // Update virtual key timestamp
-                virtualKey.UpdatedAt = DateTime.UtcNow;
-                bool success = await _virtualKeyRepository.UpdateAsync(virtualKey);
-                
-                if (success)
-                {
-                    _logger.LogInformation("Updated spend for key ID {KeyId} in group {GroupId}. New balance: {Balance}",
-                        keyId, group.Id, newBalance);
-                }
-
-                return success;
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                "Error updating spend for key ID {KeyId}.",
-                keyId);
-                return false;
-            }
-        }
-
-        /// 
-        public async Task GetVirtualKeyInfoForValidationAsync(int keyId, CancellationToken cancellationToken = default)
-        {
-            return await _virtualKeyRepository.GetByIdAsync(keyId, cancellationToken);
-        }
-
-        // Helper method to check if a model is allowed
-        private bool IsModelAllowed(string requestedModel, string allowedModels)
-        {
-            if (string.IsNullOrEmpty(allowedModels))
-                return true; // No restrictions
-
-            var allowedModelsList = allowedModels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
-
-            // First check for exact match
-            if (allowedModelsList.Any(m => string.Equals(m, requestedModel, StringComparison.OrdinalIgnoreCase)))
-                return true;
-
-            // Then check for wildcard/prefix matches
-            foreach (var allowedModel in allowedModelsList)
-            {
-                // Handle wildcards like "gpt-4*" to match any GPT-4 model
-                if (allowedModel.EndsWith("*", StringComparison.OrdinalIgnoreCase) &&
-                    allowedModel.Length > 1)
-                {
-                    string prefix = allowedModel.Substring(0, allowedModel.Length - 1);
-                    if (requestedModel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
-                        return true;
-                }
-            }
-
-            return false;
-        }
-        
-        // Helper method to generate a secure random key
-        private string GenerateSecureKey()
-        {
-            using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
-            var bytes = new byte[32]; // 256 bits
-            rng.GetBytes(bytes);
-            return Convert.ToBase64String(bytes)
-                .Replace("+", "")
-                .Replace("/", "")
-                .Replace("=", "")
-                .Substring(0, 32); // Take first 32 characters for consistency
-        }
-        
-        // Helper method to hash a key using SHA256
-        private string HashKey(string key)
-        {
-            using var sha256 = System.Security.Cryptography.SHA256.Create();
-            var bytes = System.Text.Encoding.UTF8.GetBytes(key);
-            var hash = sha256.ComputeHash(bytes);
-            
-            // Convert to hex string to match Admin API format
-            var builder = new StringBuilder();
-            foreach (byte b in hash)
-            {
-                builder.Append(b.ToString("x2"));
-            }
-            return builder.ToString();
-        }
-        
-        // Helper method to map VirtualKey entity to VirtualKeyDto
-        private VirtualKeyDto MapToDto(VirtualKey virtualKey)
-        {
-            return new VirtualKeyDto
-            {
-                Id = virtualKey.Id,
-                KeyName = virtualKey.KeyName,
-                KeyPrefix = "condt_****", // Don't expose the actual key
-                AllowedModels = virtualKey.AllowedModels,
-                VirtualKeyGroupId = virtualKey.VirtualKeyGroupId,
-                IsEnabled = virtualKey.IsEnabled,
-                ExpiresAt = virtualKey.ExpiresAt,
-                CreatedAt = virtualKey.CreatedAt,
-                UpdatedAt = virtualKey.UpdatedAt,
-                Metadata = virtualKey.Metadata,
-                RateLimitRpm = virtualKey.RateLimitRpm,
-                RateLimitRpd = virtualKey.RateLimitRpd,
-                Description = virtualKey.Description,
-            };
-        }
-    }
-}
diff --git a/Services/ConduitLLM.Gateway/Services/BatchOperationHistoryService.cs b/Services/ConduitLLM.Gateway/Services/BatchOperationHistoryService.cs
index 5c971f78f..e2647445c 100644
--- a/Services/ConduitLLM.Gateway/Services/BatchOperationHistoryService.cs
+++ b/Services/ConduitLLM.Gateway/Services/BatchOperationHistoryService.cs
@@ -87,7 +87,7 @@ public async Task RecordOperationCompletionAsync(
                 existing.DurationSeconds = result.Duration.TotalSeconds;
                 existing.ItemsPerSecond = result.ItemsPerSecond;
 
-                if (result.Status == BatchOperationStatusEnum.Failed && result.Errors.Count() > 0)
+                if (result.Status == BatchOperationStatusEnum.Failed && result.Errors.Any())
                 {
                     existing.ErrorMessage = $"{result.FailedCount} items failed";
                     existing.ErrorDetails = JsonSerializer.Serialize(result.Errors);
@@ -98,7 +98,7 @@ public async Task RecordOperationCompletionAsync(
                 }
 
                 // Store summary of results
-                if (result.ProcessedItems.Count() > 0)
+                if (result.ProcessedItems.Any())
                 {
                     var summary = new
                     {
diff --git a/Services/ConduitLLM.Gateway/Services/BatchOperationIdempotencyService.cs b/Services/ConduitLLM.Gateway/Services/BatchOperationIdempotencyService.cs
index 7545e25ce..d53c0c755 100644
--- a/Services/ConduitLLM.Gateway/Services/BatchOperationIdempotencyService.cs
+++ b/Services/ConduitLLM.Gateway/Services/BatchOperationIdempotencyService.cs
@@ -2,6 +2,7 @@
 using System.Text;
 using System.Text.Json;
 using StackExchange.Redis;
+using ConduitLLM.Configuration.Constants;
 using ConduitLLM.Core.Interfaces;
 
 namespace ConduitLLM.Gateway.Services
@@ -15,7 +16,6 @@ public class BatchOperationIdempotencyService : IBatchOperationIdempotencyServic
         private readonly IConnectionMultiplexer _redis;
         private readonly ILogger _logger;
         private readonly JsonSerializerOptions _jsonOptions;
-        private const string KeyPrefix = "batch:idempotency:";
         private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24);
 
         public BatchOperationIdempotencyService(
@@ -240,7 +240,7 @@ public async Task InvalidateTokenAsync(
 
         private static string GetRedisKey(string idempotencyToken)
         {
-            return $"{KeyPrefix}{idempotencyToken}";
+            return CacheKeys.BatchIdempotency.ByKey(idempotencyToken);
         }
     }
 }
diff --git a/Services/ConduitLLM.Gateway/Services/BatchOperationNotificationService.cs b/Services/ConduitLLM.Gateway/Services/BatchOperationNotificationService.cs
index cf5882c7e..5ec2f2c00 100644
--- a/Services/ConduitLLM.Gateway/Services/BatchOperationNotificationService.cs
+++ b/Services/ConduitLLM.Gateway/Services/BatchOperationNotificationService.cs
@@ -2,24 +2,24 @@
 using ConduitLLM.Core.Interfaces;
 using ConduitLLM.Core.Models;
 using ConduitLLM.Configuration.DTOs.SignalR;
+using ConduitLLM.Core.Services;
 using ConduitLLM.Gateway.Hubs;
 
 namespace ConduitLLM.Gateway.Services
 {
     /// 
-    /// Service for sending real-time batch operation notifications through SignalR
+    /// Service for sending real-time batch operation notifications through SignalR.
+    /// Inherits from SignalRNotificationServiceBase for standardized error handling.
     /// 
-    public class BatchOperationNotificationService : IBatchOperationNotificationService
+    public class BatchOperationNotificationService
+        : SignalRNotificationServiceBase,
+          IBatchOperationNotificationService
     {
-        private readonly IHubContext _hubContext;
-        private readonly ILogger _logger;
-
         public BatchOperationNotificationService(
             IHubContext hubContext,
             ILogger logger)
+            : base(hubContext, logger)
         {
-            _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
-            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
         }
 
         public async Task NotifyBatchOperationStartedAsync(
@@ -29,39 +29,25 @@ public async Task NotifyBatchOperationStartedAsync(
             int virtualKeyId,
             BatchOperationOptions options)
         {
-            try
+            var notification = new BatchOperationStartedNotification
             {
-                var notification = new BatchOperationStartedNotification
-                {
-                    OperationId = operationId,
-                    OperationType = operationType,
-                    TotalItems = totalItems,
-                    VirtualKeyId = virtualKeyId,
-                    MaxDegreeOfParallelism = options.MaxDegreeOfParallelism,
-                    SupportsCancellation = true,
-                    SupportsResume = options.EnableCheckpointing,
-                    StartedAt = DateTime.UtcNow,
-                    Metadata = options.Metadata
-                };
-
-                // Send to specific task subscribers
-                await _hubContext.Clients.Group($"task-{operationId}")
-                    .SendAsync("BatchOperationStarted", notification);
-
-                // Send to virtual key's batch operation subscribers
-                await _hubContext.Clients.Group($"vkey-{virtualKeyId}-batch_{operationType}")
-                    .SendAsync("BatchOperationStarted", notification);
-
-                _logger.LogInformation(
-                    "Batch operation {OperationId} of type {OperationType} started with {TotalItems} items",
-                    operationId, operationType, totalItems);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, 
-                    "Error sending BatchOperationStarted notification for operation {OperationId}",
-                    operationId);
-            }
+                OperationId = operationId,
+                OperationType = operationType,
+                TotalItems = totalItems,
+                VirtualKeyId = virtualKeyId,
+                MaxDegreeOfParallelism = options.MaxDegreeOfParallelism,
+                SupportsCancellation = true,
+                SupportsResume = options.EnableCheckpointing,
+                StartedAt = DateTime.UtcNow,
+                Metadata = options.Metadata
+            };
+
+            await SendToGroupAsync($"task-{operationId}", "BatchOperationStarted", notification);
+            await SendToGroupAsync($"vkey-{virtualKeyId}-batch_{operationType}", "BatchOperationStarted", notification);
+
+            Logger.LogInformation(
+                "Batch operation {OperationId} of type {OperationType} started with {TotalItems} items",
+                operationId, operationType, totalItems);
         }
 
         public async Task NotifyBatchOperationProgressAsync(
@@ -75,84 +61,26 @@ public async Task NotifyBatchOperationProgressAsync(
             string? currentItem = null,
             string? message = null)
         {
-            try
-            {
-                var progressPercentage = 0;
-                if (processedCount > 0)
-                {
-                    // We need total items to calculate percentage
-                    // This would be tracked in the batch operation context
-                    // For now, we'll include it in the notification
-                }
-
-                var notification = new BatchOperationProgressNotification
-                {
-                    OperationId = operationId,
-                    ProcessedCount = processedCount,
-                    SuccessCount = successCount,
-                    FailedCount = failedCount,
-                    ProgressPercentage = progressPercentage,
-                    ItemsPerSecond = itemsPerSecond,
-                    ElapsedTime = elapsedTime,
-                    EstimatedTimeRemaining = estimatedTimeRemaining,
-                    CurrentItem = currentItem,
-                    Message = message,
-                    Timestamp = DateTime.UtcNow
-                };
-
-                // Send to task subscribers
-                await _hubContext.Clients.Group($"task-{operationId}")
-                    .SendAsync("BatchOperationProgress", notification);
-
-                _logger.LogDebug(
-                    "Batch operation {OperationId} progress: {ProcessedCount} processed, {SuccessCount} succeeded, {FailedCount} failed",
-                    operationId, processedCount, successCount, failedCount);
-            }
-            catch (Exception ex)
+            var notification = new BatchOperationProgressNotification
             {
-                _logger.LogError(ex,
-                    "Error sending BatchOperationProgress notification for operation {OperationId}",
-                    operationId);
-            }
-        }
-
-        public async Task NotifyBatchItemCompletedAsync(
-            string operationId,
-            int itemIndex,
-            string? itemIdentifier,
-            bool success,
-            string? error,
-            TimeSpan duration,
-            object? result = null)
-        {
-            try
-            {
-                var notification = new BatchOperationItemCompletedNotification
-                {
-                    OperationId = operationId,
-                    ItemIndex = itemIndex,
-                    ItemIdentifier = itemIdentifier,
-                    Success = success,
-                    Error = error,
-                    Duration = duration,
-                    Result = result,
-                    CompletedAt = DateTime.UtcNow
-                };
-
-                // Send to task subscribers who want item-level updates
-                await _hubContext.Clients.Group($"task-{operationId}-items")
-                    .SendAsync("BatchItemCompleted", notification);
-
-                _logger.LogDebug(
-                    "Batch operation {OperationId} item {ItemIndex} completed: {Success}",
-                    operationId, itemIndex, success ? "Success" : "Failed");
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                    "Error sending BatchItemCompleted notification for operation {OperationId} item {ItemIndex}",
-                    operationId, itemIndex);
-            }
+                OperationId = operationId,
+                ProcessedCount = processedCount,
+                SuccessCount = successCount,
+                FailedCount = failedCount,
+                ProgressPercentage = 0,
+                ItemsPerSecond = itemsPerSecond,
+                ElapsedTime = elapsedTime,
+                EstimatedTimeRemaining = estimatedTimeRemaining,
+                CurrentItem = currentItem,
+                Message = message,
+                Timestamp = DateTime.UtcNow
+            };
+
+            await SendToGroupAsync($"task-{operationId}", "BatchOperationProgress", notification);
+
+            Logger.LogDebug(
+                "Batch operation {OperationId} progress: {ProcessedCount} processed, {SuccessCount} succeeded, {FailedCount} failed",
+                operationId, processedCount, successCount, failedCount);
         }
 
         public async Task NotifyBatchOperationCompletedAsync(
@@ -166,37 +94,26 @@ public async Task NotifyBatchOperationCompletedAsync(
             double averageItemsPerSecond,
             object? resultSummary = null)
         {
-            try
-            {
-                var notification = new BatchOperationCompletedNotification
-                {
-                    OperationId = operationId,
-                    OperationType = operationType,
-                    Status = status.ToString(),
-                    TotalItems = totalItems,
-                    SuccessCount = successCount,
-                    FailedCount = failedCount,
-                    Duration = duration,
-                    AverageItemsPerSecond = averageItemsPerSecond,
-                    CompletedAt = DateTime.UtcNow,
-                    ResultSummary = resultSummary,
-                    Errors = new List() // Would be populated from context
-                };
-
-                // Send to task subscribers
-                await _hubContext.Clients.Group($"task-{operationId}")
-                    .SendAsync("BatchOperationCompleted", notification);
-
-                _logger.LogInformation(
-                    "Batch operation {OperationId} completed with status {Status}: {SuccessCount}/{TotalItems} succeeded in {Duration}",
-                    operationId, status, successCount, totalItems, duration);
-            }
-            catch (Exception ex)
+            var notification = new BatchOperationCompletedNotification
             {
-                _logger.LogError(ex,
-                    "Error sending BatchOperationCompleted notification for operation {OperationId}",
-                    operationId);
-            }
+                OperationId = operationId,
+                OperationType = operationType,
+                Status = status.ToString(),
+                TotalItems = totalItems,
+                SuccessCount = successCount,
+                FailedCount = failedCount,
+                Duration = duration,
+                AverageItemsPerSecond = averageItemsPerSecond,
+                CompletedAt = DateTime.UtcNow,
+                ResultSummary = resultSummary,
+                Errors = new List()
+            };
+
+            await SendToGroupAsync($"task-{operationId}", "BatchOperationCompleted", notification);
+
+            Logger.LogInformation(
+                "Batch operation {OperationId} completed with status {Status}: {SuccessCount}/{TotalItems} succeeded in {Duration}",
+                operationId, status, successCount, totalItems, duration);
         }
 
         public async Task NotifyBatchOperationFailedAsync(
@@ -208,34 +125,23 @@ public async Task NotifyBatchOperationFailedAsync(
             int failedCount,
             string? stackTrace = null)
         {
-            try
+            var notification = new BatchOperationFailedNotification
             {
-                var notification = new BatchOperationFailedNotification
-                {
-                    OperationId = operationId,
-                    OperationType = operationType,
-                    Error = error,
-                    IsRetryable = isRetryable,
-                    ProcessedCount = processedCount,
-                    FailedCount = failedCount,
-                    FailedAt = DateTime.UtcNow,
-                    StackTrace = stackTrace
-                };
-
-                // Send to task subscribers
-                await _hubContext.Clients.Group($"task-{operationId}")
-                    .SendAsync("BatchOperationFailed", notification);
-
-                _logger.LogError(
-                    "Batch operation {OperationId} failed: {Error}. Processed: {ProcessedCount}, Failed: {FailedCount}",
-                    operationId, error, processedCount, failedCount);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex,
-                    "Error sending BatchOperationFailed notification for operation {OperationId}",
-                    operationId);
-            }
+                OperationId = operationId,
+                OperationType = operationType,
+                Error = error,
+                IsRetryable = isRetryable,
+                ProcessedCount = processedCount,
+                FailedCount = failedCount,
+                FailedAt = DateTime.UtcNow,
+                StackTrace = stackTrace
+            };
+
+            await SendToGroupAsync($"task-{operationId}", "BatchOperationFailed", notification);
+
+            Logger.LogError(
+                "Batch operation {OperationId} failed: {Error}. Processed: {ProcessedCount}, Failed: {FailedCount}",
+                operationId, error, processedCount, failedCount);
         }
 
         public async Task NotifyBatchOperationCancelledAsync(
@@ -246,33 +152,22 @@ public async Task NotifyBatchOperationCancelledAsync(
             int remainingCount,
             bool canResume)
         {
-            try
-            {
-                var notification = new BatchOperationCancelledNotification
-                {
-                    OperationId = operationId,
-                    OperationType = operationType,
-                    Reason = reason,
-                    ProcessedCount = processedCount,
-                    RemainingCount = remainingCount,
-                    CanResume = canResume,
-                    CancelledAt = DateTime.UtcNow
-                };
-
-                // Send to task subscribers
-                await _hubContext.Clients.Group($"task-{operationId}")
-                    .SendAsync("BatchOperationCancelled", notification);
-
-                _logger.LogInformation(
-                    "Batch operation {OperationId} cancelled: {Reason}. Processed: {ProcessedCount}, Remaining: {RemainingCount}",
-                    operationId, reason ?? "User requested", processedCount, remainingCount);
-            }
-            catch (Exception ex)
+            var notification = new BatchOperationCancelledNotification
             {
-                _logger.LogError(ex,
-                    "Error sending BatchOperationCancelled notification for operation {OperationId}",
-                    operationId);
-            }
+                OperationId = operationId,
+                OperationType = operationType,
+                Reason = reason,
+                ProcessedCount = processedCount,
+                RemainingCount = remainingCount,
+                CanResume = canResume,
+                CancelledAt = DateTime.UtcNow
+            };
+
+            await SendToGroupAsync($"task-{operationId}", "BatchOperationCancelled", notification);
+
+            Logger.LogInformation(
+                "Batch operation {OperationId} cancelled: {Reason}. Processed: {ProcessedCount}, Remaining: {RemainingCount}",
+                operationId, reason ?? "User requested", processedCount, remainingCount);
         }
     }
-}
\ No newline at end of file
+}
diff --git a/Services/ConduitLLM.Gateway/Services/BusinessMetricsService.cs b/Services/ConduitLLM.Gateway/Services/BusinessMetricsService.cs
index 4dd77ce82..9d0891981 100644
--- a/Services/ConduitLLM.Gateway/Services/BusinessMetricsService.cs
+++ b/Services/ConduitLLM.Gateway/Services/BusinessMetricsService.cs
@@ -142,7 +142,8 @@ public BusinessMetricsService(
 
         protected override async Task ExecuteAsync(CancellationToken stoppingToken)
         {
-            _logger.LogInformation("Business metrics service starting...");
+            _logger.LogInformation("Business metrics service starting with {IntervalSeconds}s collection interval",
+                _collectionInterval.TotalSeconds);
 
             while (!stoppingToken.IsCancellationRequested)
             {
@@ -163,6 +164,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 
         private async Task CollectMetricsAsync()
         {
+            var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+
             using var scope = _serviceScopeFactory.CreateScope();
 
             var tasks = new[]
@@ -174,21 +177,21 @@ private async Task CollectMetricsAsync()
             };
 
             await Task.WhenAll(tasks);
+
+            stopwatch.Stop();
+            _logger.LogDebug("Business metrics collection cycle completed in {ElapsedMs}ms", stopwatch.ElapsedMilliseconds);
         }
 
         private async Task CollectVirtualKeyMetrics(IServiceScope scope)
         {
             try
             {
-                var virtualKeyRepo = scope.ServiceProvider.GetRequiredService();
-                var spendHistoryRepo = scope.ServiceProvider.GetRequiredService();
-
-                // Get all virtual keys and filter for active ones
-                var allKeys = await virtualKeyRepo.GetAllAsync();
-                var activeKeys = allKeys.Where(k => k.IsEnabled && (k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow)).ToList();
-
                 // Note: Budget tracking is now at the group level
                 // Individual key metrics are no longer tracked for budget/spend
+                // No need to load all virtual keys - just count active ones if needed
+                var virtualKeyRepo = scope.ServiceProvider.GetRequiredService();
+                var activeKeyCount = await virtualKeyRepo.CountActiveAsync();
+                // activeKeyCount is available for metrics if needed in the future
             }
             catch (Exception ex)
             {
@@ -303,9 +306,8 @@ private async Task CollectActiveEntityMetrics(IServiceScope scope)
                 var virtualKeyRepo = scope.ServiceProvider.GetRequiredService();
                 var modelMappingService = scope.ServiceProvider.GetRequiredService();
 
-                // Count active virtual keys
-                var allKeys = await virtualKeyRepo.GetAllAsync();
-                var activeKeyCount = allKeys.Count(k => k.IsEnabled && (k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow));
+                // Count active virtual keys using database-level count
+                var activeKeyCount = await virtualKeyRepo.CountActiveAsync();
                 ActiveVirtualKeys.Set(activeKeyCount);
 
                 // Count active model mappings by provider
@@ -355,7 +357,8 @@ public static void RecordCost(string provider, string model, string operationTyp
             CostPerRequest.WithLabels(model, provider).Observe(costDollars);
         }
 
-        public static void RecordTokens(string model, string provider, int promptTokens, int completionTokens)
+        public static void RecordTokens(string model, string provider, int promptTokens, int completionTokens,
+            int? cachedInputTokens = null, int? cachedWriteTokens = null)
         {
             if (promptTokens > 0)
             {
@@ -365,6 +368,14 @@ public static void RecordTokens(string model, string provider, int promptTokens,
             {
                 ModelTokensProcessed.WithLabels(model, provider, "completion").Inc(completionTokens);
             }
+            if (cachedInputTokens.HasValue && cachedInputTokens.Value > 0)
+            {
+                ModelTokensProcessed.WithLabels(model, provider, "cached_input").Inc(cachedInputTokens.Value);
+            }
+            if (cachedWriteTokens.HasValue && cachedWriteTokens.Value > 0)
+            {
+                ModelTokensProcessed.WithLabels(model, provider, "cached_write").Inc(cachedWriteTokens.Value);
+            }
         }
 
         public static void RecordResponseTime(string model, string provider, double responseTimeSeconds)
diff --git a/Services/ConduitLLM.Gateway/Services/CachedApiVirtualKeyService.cs b/Services/ConduitLLM.Gateway/Services/CachedApiVirtualKeyService.cs
index f6a3969d5..047860c6e 100644
--- a/Services/ConduitLLM.Gateway/Services/CachedApiVirtualKeyService.cs
+++ b/Services/ConduitLLM.Gateway/Services/CachedApiVirtualKeyService.cs
@@ -2,6 +2,7 @@
 using ConduitLLM.Core.Extensions;
 using ConduitLLM.Configuration.DTOs.VirtualKey;
 using ConduitLLM.Configuration.Interfaces;
+using VirtualKeyUtilities = ConduitLLM.Configuration.Utilities.VirtualKeyUtilities;
 using ConduitLLM.Core.Events;
 using ConduitLLM.Core.Services;
 using MassTransit;
@@ -13,11 +14,8 @@ namespace ConduitLLM.Gateway.Services
     /// High-performance Virtual Key service with Redis caching and immediate invalidation
     /// Maintains security guarantees while providing ~50x performance improvement
     /// 
- public class CachedApiVirtualKeyService : EventPublishingServiceBase, IVirtualKeyService + public class CachedApiVirtualKeyService : VirtualKeyServiceBase, IVirtualKeyService { - private readonly IVirtualKeyRepository _virtualKeyRepository; - private readonly IVirtualKeySpendHistoryRepository _spendHistoryRepository; - private readonly IVirtualKeyGroupRepository _groupRepository; private readonly ConduitLLM.Core.Interfaces.IVirtualKeyCache _cache; private readonly ILogger _logger; @@ -28,18 +26,26 @@ public CachedApiVirtualKeyService( ConduitLLM.Core.Interfaces.IVirtualKeyCache cache, IPublishEndpoint? publishEndpoint, ILogger logger) - : base(publishEndpoint, logger) + : base(virtualKeyRepository, groupRepository, spendHistoryRepository, publishEndpoint, logger) { - _virtualKeyRepository = virtualKeyRepository ?? throw new ArgumentNullException(nameof(virtualKeyRepository)); - _spendHistoryRepository = spendHistoryRepository ?? throw new ArgumentNullException(nameof(spendHistoryRepository)); - _groupRepository = groupRepository ?? throw new ArgumentNullException(nameof(groupRepository)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Log event publishing configuration status - LogEventPublishingConfiguration(nameof(CachedApiVirtualKeyService)); } + #region Virtual Key Hooks (Cache Invalidation) + + protected override async Task OnVirtualKeyUpdatedAsync(VirtualKey key, string[] changedProperties) + { + await _cache.InvalidateVirtualKeyAsync(key.KeyHash); + } + + protected override async Task OnVirtualKeyDeletedAsync(VirtualKey key) + { + await _cache.InvalidateVirtualKeyAsync(key.KeyHash); + } + + #endregion + /// /// Validates virtual key for authentication only (no balance check) /// @@ -57,14 +63,14 @@ public CachedApiVirtualKeyService( try { var keyHash = VirtualKeyUtilities.HashKey(key); - _logger.LogDebug("Validating key for authentication: {KeyPrefix}..., Hash: {Hash}", - key.Length > 10 ? key.Substring(0, 10) : key, keyHash); - + _logger.LogDebug("Validating key for authentication: {KeyPrefix}..., Hash: {Hash}", + LoggingSanitizer.S(key.Length > 10 ? key.Substring(0, 10) : key), keyHash); + // Use cache with database fallback - var virtualKey = await _cache.GetVirtualKeyAsync(keyHash, async hash => + var virtualKey = await _cache.GetVirtualKeyAsync(keyHash, async hash => { // This fallback only runs on cache miss - var dbKey = await _virtualKeyRepository.GetByKeyHashAsync(hash); + var dbKey = await VirtualKeyRepository.GetByKeyHashAsync(hash); _logger.LogDebug("Database fallback executed for Virtual Key authentication validation"); return dbKey; }); @@ -77,10 +83,10 @@ public CachedApiVirtualKeyService( // Validate without balance check var validationResult = await VirtualKeyValidationHelper.ValidateVirtualKeyAsync( - virtualKey, - requestedModel, - checkBalance: false, - groupRepository: null, + virtualKey, + requestedModel, + checkBalance: false, + groupRepository: null, _logger); return validationResult.IsValid ? virtualKey : null; @@ -104,14 +110,14 @@ public CachedApiVirtualKeyService( try { var keyHash = VirtualKeyUtilities.HashKey(key); - _logger.LogDebug("Validating key: {KeyPrefix}..., Hash: {Hash}", - key.Length > 10 ? key.Substring(0, 10) : key, keyHash); - + _logger.LogDebug("Validating key: {KeyPrefix}..., Hash: {Hash}", + LoggingSanitizer.S(key.Length > 10 ? key.Substring(0, 10) : key), keyHash); + // Use cache with database fallback - var virtualKey = await _cache.GetVirtualKeyAsync(keyHash, async hash => + var virtualKey = await _cache.GetVirtualKeyAsync(keyHash, async hash => { // This fallback only runs on cache miss - var dbKey = await _virtualKeyRepository.GetByKeyHashAsync(hash); + var dbKey = await VirtualKeyRepository.GetByKeyHashAsync(hash); _logger.LogDebug("Database fallback executed for Virtual Key validation"); return dbKey; }); @@ -124,20 +130,23 @@ public CachedApiVirtualKeyService( // Validate with balance check var validationResult = await VirtualKeyValidationHelper.ValidateVirtualKeyAsync( - virtualKey, - requestedModel, - checkBalance: true, - _groupRepository, + virtualKey, + requestedModel, + checkBalance: true, + GroupRepository, _logger); if (!validationResult.IsValid) { + _logger.LogWarning("Virtual key {KeyId} validation failed: {Reason}", + virtualKey.Id, validationResult.Reason ?? "unknown"); + // Handle 402 status code for insufficient balance if (validationResult.StatusCode == 402) { // Note: This violates clean architecture but is pragmatic // TODO: Find a better way to handle this - try + try { var httpContext = new Microsoft.AspNetCore.Http.HttpContextAccessor().HttpContext; if (httpContext != null) @@ -145,7 +154,10 @@ public CachedApiVirtualKeyService( httpContext.Response.StatusCode = 402; } } - catch { /* Ignore if no HTTP context */ } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not set 402 status โ€” HTTP context not available for insufficient balance response"); + } } return null; } @@ -159,217 +171,10 @@ public CachedApiVirtualKeyService( } } - /// - public async Task GenerateVirtualKeyAsync(CreateVirtualKeyRequestDto request) - { - try - { - // Generate a new key with prefix - var keyValue = VirtualKeyUtilities.GenerateSecureKey(); - var keyWithPrefix = $"condt_{keyValue}"; - - // Hash the key for storage - var keyHash = VirtualKeyUtilities.HashKey(keyWithPrefix); - - // VirtualKeyGroupId is now required - var groupId = request.VirtualKeyGroupId; - - // Create the virtual key entity - var virtualKey = new VirtualKey - { - KeyName = request.KeyName ?? string.Empty, - KeyHash = keyHash, - AllowedModels = request.AllowedModels, - VirtualKeyGroupId = groupId, - IsEnabled = true, - ExpiresAt = request.ExpiresAt, - Metadata = request.Metadata, - RateLimitRpm = request.RateLimitRpm, - RateLimitRpd = request.RateLimitRpd, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - // Save to database - var createdId = await _virtualKeyRepository.CreateAsync(virtualKey); - - if (createdId > 0) - { - // Retrieve the created virtual key to get all populated fields - var created = await _virtualKeyRepository.GetByIdAsync(createdId); - if (created != null) - { - _logger.LogInformation("Created new virtual key: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(created.KeyName), created.Id); - - // Return the response with the actual key (only shown once) - return new CreateVirtualKeyResponseDto - { - VirtualKey = keyWithPrefix, - KeyInfo = VirtualKeyUtilities.MapToDto(created) - }; - } - } - - throw new InvalidOperationException("Failed to create virtual key"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating virtual key"); - throw; - } - } - - /// - public async Task GetVirtualKeyInfoAsync(int id) - { - try - { - var virtualKey = await _virtualKeyRepository.GetByIdAsync(id); - if (virtualKey == null) - { - _logger.LogWarning("Virtual key with ID {KeyId} not found", id); - return null; - } - - return VirtualKeyUtilities.MapToDto(virtualKey); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving virtual key info for ID {KeyId}", id); - throw; - } - } - - /// - public async Task> ListVirtualKeysAsync() - { - try - { - var virtualKeys = await _virtualKeyRepository.GetAllAsync(); - return [..virtualKeys.Select(VirtualKeyUtilities.MapToDto)]; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error listing virtual keys"); - throw; - } - } - - /// - public async Task UpdateVirtualKeyAsync(int id, UpdateVirtualKeyRequestDto request) - { - try - { - var virtualKey = await _virtualKeyRepository.GetByIdAsync(id); - if (virtualKey == null) - { - _logger.LogWarning("Virtual key with ID {KeyId} not found for update", id); - return false; - } - - // Update fields only if provided (null means no change) - if (request.KeyName != null) - virtualKey.KeyName = request.KeyName; - - if (request.AllowedModels != null) - virtualKey.AllowedModels = string.IsNullOrEmpty(request.AllowedModels) ? null : request.AllowedModels; - - if (request.VirtualKeyGroupId.HasValue) - virtualKey.VirtualKeyGroupId = request.VirtualKeyGroupId.Value; - - if (request.IsEnabled.HasValue) - virtualKey.IsEnabled = request.IsEnabled.Value; - - if (request.ExpiresAt.HasValue) - virtualKey.ExpiresAt = request.ExpiresAt.Value; - - if (request.Metadata != null) - virtualKey.Metadata = string.IsNullOrEmpty(request.Metadata) ? null : request.Metadata; - - if (request.RateLimitRpm.HasValue) - virtualKey.RateLimitRpm = request.RateLimitRpm.Value; - - if (request.RateLimitRpd.HasValue) - virtualKey.RateLimitRpd = request.RateLimitRpd.Value; - - virtualKey.UpdatedAt = DateTime.UtcNow; - - var success = await _virtualKeyRepository.UpdateAsync(virtualKey); - - if (success) - { - // SECURITY CRITICAL: Immediately invalidate cache - await _cache.InvalidateVirtualKeyAsync(virtualKey.KeyHash); - _logger.LogInformation("Updated virtual key: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(virtualKey.KeyName), id); - } - - return success; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating virtual key with ID {KeyId}", id); - return false; - } - } - - /// - public async Task DeleteVirtualKeyAsync(int id) - { - try - { - var virtualKey = await _virtualKeyRepository.GetByIdAsync(id); - if (virtualKey == null) - { - _logger.LogWarning("Virtual key with ID {KeyId} not found for deletion", id); - return false; - } - - var success = await _virtualKeyRepository.DeleteAsync(id); - - if (success) - { - // SECURITY CRITICAL: Immediately invalidate cache - await _cache.InvalidateVirtualKeyAsync(virtualKey.KeyHash); - _logger.LogInformation("Deleted virtual key: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(virtualKey.KeyName), id); - } - - return success; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting virtual key with ID {KeyId}", id); - return false; - } - } - - /// - public async Task ResetSpendAsync(int id) - { - var virtualKey = await _virtualKeyRepository.GetByIdAsync(id); - if (virtualKey == null) return false; - - try - { - // Budget tracking is now at the group level - // This method is deprecated but kept for compatibility - _logger.LogWarning("ResetSpendAsync called for key {KeyId} - this operation is no longer supported", id); - - // Still invalidate cache for consistency - await _cache.InvalidateVirtualKeyAsync(virtualKey.KeyHash); - - return false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resetting spend for virtual key with ID {KeyId}", id); - return false; - } - } - /// public async Task UpdateSpendAsync(int keyId, decimal cost) { - if (cost <= 0) + if (cost <= 0) { _logger.LogDebug("Spend update for key {KeyId} has zero or negative cost {Cost} - skipping", keyId, cost); return true; // No cost to add, consider it successful @@ -381,7 +186,7 @@ public async Task UpdateSpendAsync(int keyId, decimal cost) { // Event-driven approach - publish SpendUpdateRequested event var requestId = Guid.NewGuid().ToString(); - + await PublishEventAsync( new SpendUpdateRequested { @@ -392,7 +197,7 @@ await PublishEventAsync( }, $"spend update for key {keyId}", new { KeyId = keyId, Amount = cost, RequestId = requestId }); - + // Event-driven approach returns true immediately - processing happens asynchronously // The SpendUpdateProcessor will handle the actual database update and cache invalidation return true; @@ -401,30 +206,30 @@ await PublishEventAsync( { // FALLBACK: Direct database update approach when event bus not configured _logger.LogDebug("Event publishing not configured - using direct database update for key {KeyId}", keyId); - - var virtualKey = await _virtualKeyRepository.GetByIdAsync(keyId); - if (virtualKey == null) + + var virtualKey = await VirtualKeyRepository.GetByIdAsync(keyId); + if (virtualKey == null) { _logger.LogWarning("Virtual key {KeyId} not found for spend update", keyId); return false; } // Get the key's group and adjust its balance - var group = await _groupRepository.GetByKeyIdAsync(keyId); + var group = await GroupRepository.GetByKeyIdAsync(keyId); if (group == null) { _logger.LogWarning("No group found for virtual key with ID {KeyId}", keyId); return false; } - var newBalance = await _groupRepository.AdjustBalanceAsync(group.Id, -cost); - + var newBalance = await GroupRepository.AdjustBalanceAsync(group.Id, -cost); + // Invalidate cache after spend update await _cache.InvalidateVirtualKeyAsync(virtualKey.KeyHash); - + _logger.LogInformation("Updated spend for key ID {KeyId} in group {GroupId}. New balance: {NewBalance}", keyId, group.Id, newBalance); - + bool success = true; return success; @@ -440,7 +245,7 @@ await PublishEventAsync( /// public async Task GetVirtualKeyInfoForValidationAsync(int keyId, CancellationToken cancellationToken = default) { - return await _virtualKeyRepository.GetByIdAsync(keyId, cancellationToken); + return await VirtualKeyRepository.GetByIdAsync(keyId, cancellationToken); } /// @@ -451,4 +256,4 @@ await PublishEventAsync( return await _cache.GetStatsAsync(); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/ContentGenerationNotificationService.cs b/Services/ConduitLLM.Gateway/Services/ContentGenerationNotificationService.cs deleted file mode 100644 index f612bd00f..000000000 --- a/Services/ConduitLLM.Gateway/Services/ContentGenerationNotificationService.cs +++ /dev/null @@ -1,357 +0,0 @@ -using Microsoft.AspNetCore.SignalR; -using ConduitLLM.Gateway.Hubs; -using ConduitLLM.Gateway.Interfaces; - -namespace ConduitLLM.Gateway.Services -{ - /// - /// Unified implementation of content generation notification service using SignalR. - /// Handles both image and video generation notifications through ContentGenerationHub. - /// - public class ContentGenerationNotificationService : IContentGenerationNotificationService - { - private readonly IHubContext _hubContext; - private readonly ILogger _logger; - - public ContentGenerationNotificationService( - IHubContext hubContext, - ILogger logger) - { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - // Image Generation Events - - public async Task NotifyImageGenerationStartedAsync(string taskId, string prompt, int numberOfImages, string size, string? style = null) - { - try - { - var notification = new - { - taskId, - prompt, - numberOfImages, - size, - style, - startedAt = DateTime.UtcNow - }; - - // Send to image-specific group - await _hubContext.Clients.Group($"image-{taskId}").SendAsync("ImageGenerationStarted", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationStarted", new - { - taskId, - contentType = "image", - details = notification - }); - - _logger.LogInformation( - "[SignalR:ImageGenerationStarted] Sent notification - TaskId: {TaskId}, Prompt: {Prompt}, NumberOfImages: {NumberOfImages}, Size: {Size}, Style: {Style}", - taskId, prompt.Length > 50 ? prompt.Substring(0, 50) + "..." : prompt, numberOfImages, size, style ?? "default"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationStarted notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationProgressAsync(string taskId, int progressPercentage, string status, int imagesCompleted, int totalImages, string? message = null) - { - try - { - var notification = new - { - taskId, - progressPercentage, - status, - imagesCompleted, - totalImages, - message, - timestamp = DateTime.UtcNow - }; - - // Send to image-specific group - await _hubContext.Clients.Group($"image-{taskId}").SendAsync("ImageGenerationProgress", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationProgress", new - { - taskId, - contentType = "image", - progressPercentage, - details = notification - }); - - _logger.LogDebug("Sent ImageGenerationProgress notification for task {TaskId}: {Progress}% ({ImagesCompleted}/{TotalImages})", - taskId, progressPercentage, imagesCompleted, totalImages); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationProgress notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationCompletedAsync(string taskId, string[] imageUrls, TimeSpan duration, decimal cost) - { - try - { - var notification = new - { - taskId, - imageUrls, - durationSeconds = duration.TotalSeconds, - cost, - completedAt = DateTime.UtcNow - }; - - // Send to image-specific group - await _hubContext.Clients.Group($"image-{taskId}").SendAsync("ImageGenerationCompleted", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationCompleted", new - { - taskId, - contentType = "image", - details = notification - }); - - _logger.LogDebug("Sent ImageGenerationCompleted notification for task {TaskId} with {ImageCount} images", - taskId, imageUrls.Length); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationCompleted notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationFailedAsync(string taskId, string error, bool isRetryable) - { - try - { - var notification = new - { - taskId, - error, - isRetryable, - failedAt = DateTime.UtcNow - }; - - // Send to image-specific group - await _hubContext.Clients.Group($"image-{taskId}").SendAsync("ImageGenerationFailed", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationFailed", new - { - taskId, - contentType = "image", - error, - isRetryable, - details = notification - }); - - _logger.LogDebug("Sent ImageGenerationFailed notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationFailed notification for task {TaskId}", taskId); - } - } - - public async Task NotifyImageGenerationCancelledAsync(string taskId, string? reason) - { - try - { - var notification = new - { - taskId, - reason, - cancelledAt = DateTime.UtcNow - }; - - // Send to image-specific group - await _hubContext.Clients.Group($"image-{taskId}").SendAsync("ImageGenerationCancelled", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationCancelled", new - { - taskId, - contentType = "image", - reason, - details = notification - }); - - _logger.LogDebug("Sent ImageGenerationCancelled notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send ImageGenerationCancelled notification for task {TaskId}", taskId); - } - } - - // Video Generation Events - - public async Task NotifyVideoGenerationStartedAsync(string taskId, string provider, DateTime startedAt, int? estimatedSeconds) - { - try - { - var notification = new - { - taskId, - provider, - startedAt, - estimatedSeconds - }; - - // Send to video-specific group - await _hubContext.Clients.Group($"video-{taskId}").SendAsync("VideoGenerationStarted", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationStarted", new - { - taskId, - contentType = "video", - details = notification - }); - - _logger.LogDebug("Sent VideoGenerationStarted notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VideoGenerationStarted notification for task {TaskId}", taskId); - } - } - - public async Task NotifyVideoGenerationProgressAsync(string taskId, int progressPercentage, string status, string? message = null) - { - try - { - var notification = new - { - taskId, - progressPercentage, - status, - message, - timestamp = DateTime.UtcNow - }; - - // Send to video-specific group - await _hubContext.Clients.Group($"video-{taskId}").SendAsync("VideoGenerationProgress", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationProgress", new - { - taskId, - contentType = "video", - progressPercentage, - details = notification - }); - - _logger.LogDebug("Sent VideoGenerationProgress notification for task {TaskId}: {Progress}%", - taskId, progressPercentage); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VideoGenerationProgress notification for task {TaskId}", taskId); - } - } - - public async Task NotifyVideoGenerationCompletedAsync(string taskId, string videoUrl, TimeSpan duration, decimal cost) - { - try - { - var notification = new - { - taskId, - videoUrl, - durationSeconds = duration.TotalSeconds, - cost, - completedAt = DateTime.UtcNow - }; - - // Send to video-specific group - await _hubContext.Clients.Group($"video-{taskId}").SendAsync("VideoGenerationCompleted", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationCompleted", new - { - taskId, - contentType = "video", - details = notification - }); - - _logger.LogDebug("Sent VideoGenerationCompleted notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VideoGenerationCompleted notification for task {TaskId}", taskId); - } - } - - public async Task NotifyVideoGenerationFailedAsync(string taskId, string error, bool isRetryable) - { - try - { - var notification = new - { - taskId, - error, - isRetryable, - failedAt = DateTime.UtcNow - }; - - // Send to video-specific group - await _hubContext.Clients.Group($"video-{taskId}").SendAsync("VideoGenerationFailed", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationFailed", new - { - taskId, - contentType = "video", - error, - isRetryable, - details = notification - }); - - _logger.LogDebug("Sent VideoGenerationFailed notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VideoGenerationFailed notification for task {TaskId}", taskId); - } - } - - public async Task NotifyVideoGenerationCancelledAsync(string taskId, string? reason) - { - try - { - var notification = new - { - taskId, - reason, - cancelledAt = DateTime.UtcNow - }; - - // Send to video-specific group - await _hubContext.Clients.Group($"video-{taskId}").SendAsync("VideoGenerationCancelled", notification); - - // Also send to unified content group - await _hubContext.Clients.Group($"content-{taskId}").SendAsync("ContentGenerationCancelled", new - { - taskId, - contentType = "video", - reason, - details = notification - }); - - _logger.LogDebug("Sent VideoGenerationCancelled notification for task {TaskId}", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VideoGenerationCancelled notification for task {TaskId}", taskId); - } - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/DirectApiVirtualKeyService.cs b/Services/ConduitLLM.Gateway/Services/DirectApiVirtualKeyService.cs new file mode 100644 index 000000000..9cbe16d4d --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/DirectApiVirtualKeyService.cs @@ -0,0 +1,157 @@ +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Enums; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Services; +using VirtualKeyUtilities = ConduitLLM.Configuration.Utilities.VirtualKeyUtilities; + +using MassTransit; +using IVirtualKeyService = ConduitLLM.Core.Interfaces.IVirtualKeyService; + +namespace ConduitLLM.Gateway.Services +{ + /// + /// Lightweight Gateway implementation of IVirtualKeyService that extends VirtualKeyServiceBase + /// for shared CRUD and adds Gateway-specific validation and spend tracking. + /// + /// + /// All CRUD operations (Generate, Get, List, Update, Delete, ResetSpend) are inherited + /// from . This class only implements the four + /// Gateway-specific methods: authentication validation, full validation, spend updates, + /// and raw entity retrieval for validation. + /// + public class DirectApiVirtualKeyService : VirtualKeyServiceBase, IVirtualKeyService + { + /// + /// Initializes a new instance of the DirectApiVirtualKeyService + /// + public DirectApiVirtualKeyService( + IVirtualKeyRepository virtualKeyRepository, + IVirtualKeyGroupRepository groupRepository, + IVirtualKeySpendHistoryRepository spendHistoryRepository, + IPublishEndpoint? publishEndpoint, + ILogger logger) + : base(virtualKeyRepository, groupRepository, spendHistoryRepository, publishEndpoint, logger) + { + } + + /// + public async Task ValidateVirtualKeyForAuthenticationAsync(string key, string? requestedModel = null) + { + if (string.IsNullOrEmpty(key)) + { + Logger.LogWarning("Empty key provided for authentication validation"); + return null; + } + + // Hash the incoming key before looking it up + var keyHash = VirtualKeyUtilities.HashKey(key); + Logger.LogDebug("Validating key for authentication: {KeyPrefix}..., Hash: {Hash}", + LoggingSanitizer.S(key.Length > 10 ? key.Substring(0, 10) : key), keyHash); + + var virtualKey = await VirtualKeyRepository.GetByKeyHashAsync(keyHash); + if (virtualKey == null) + { + Logger.LogWarning("No matching virtual key found for hash: {Hash}", keyHash); + return null; + } + + // Delegate to shared validation helper (no balance check for authentication) + var result = await VirtualKeyValidationHelper.ValidateVirtualKeyAsync( + virtualKey, requestedModel, checkBalance: false, GroupRepository, Logger); + + return result.IsValid ? virtualKey : null; + } + + /// + public async Task ValidateVirtualKeyAsync(string key, string? requestedModel = null) + { + if (string.IsNullOrEmpty(key)) + { + Logger.LogWarning("Empty key provided for validation"); + return null; + } + + // Hash the incoming key before looking it up + var keyHash = VirtualKeyUtilities.HashKey(key); + Logger.LogDebug("Validating key: {KeyPrefix}..., Hash: {Hash}", + LoggingSanitizer.S(key.Length > 10 ? key.Substring(0, 10) : key), keyHash); + + var virtualKey = await VirtualKeyRepository.GetByKeyHashAsync(keyHash); + if (virtualKey == null) + { + Logger.LogWarning("No matching virtual key found for hash: {Hash}", keyHash); + return null; + } + + // Delegate to shared validation helper (with balance check) + var result = await VirtualKeyValidationHelper.ValidateVirtualKeyAsync( + virtualKey, requestedModel, checkBalance: true, GroupRepository, Logger); + + if (!result.IsValid) + { + Logger.LogWarning("Virtual key {KeyId} validation failed: {Reason}", + virtualKey.Id, result.Reason ?? "unknown"); + return null; + } + + Logger.LogDebug("Virtual key {KeyId} validated successfully for model: {Model}", + virtualKey.Id, LoggingSanitizer.S(requestedModel ?? "any")); + return virtualKey; + } + + /// + public async Task UpdateSpendAsync(int keyId, decimal cost) + { + if (cost <= 0) return true; // No cost to add, consider it successful + + var virtualKey = await VirtualKeyRepository.GetByIdAsync(keyId); + if (virtualKey == null) return false; + + try + { + // Get the key's group + var group = await GroupRepository.GetByIdAsync(virtualKey.VirtualKeyGroupId); + if (group == null) + { + Logger.LogError("Virtual key {KeyId} has invalid group ID {GroupId}", keyId, virtualKey.VirtualKeyGroupId); + return false; + } + + // Update the group balance + var newBalance = await GroupRepository.AdjustBalanceAsync( + group.Id, + -cost, + $"API usage by virtual key #{keyId}", + "System", + ReferenceType.VirtualKey, + keyId.ToString()); + + // Update virtual key timestamp + virtualKey.UpdatedAt = DateTime.UtcNow; + bool success = await VirtualKeyRepository.UpdateAsync(virtualKey); + + if (success) + { + Logger.LogInformation("Updated spend for key ID {KeyId} in group {GroupId}. New balance: {Balance}", + keyId, group.Id, newBalance); + } + + return success; + } + catch (Exception ex) + { + Logger.LogError(ex, + "Error updating spend for key ID {KeyId}.", + keyId); + return false; + } + } + + /// + public async Task GetVirtualKeyInfoForValidationAsync(int keyId, CancellationToken cancellationToken = default) + { + return await VirtualKeyRepository.GetByIdAsync(keyId, cancellationToken); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/DiscoveryCacheWarmingService.cs b/Services/ConduitLLM.Gateway/Services/DiscoveryCacheWarmingService.cs index 239188c6a..fb9b0c525 100644 --- a/Services/ConduitLLM.Gateway/Services/DiscoveryCacheWarmingService.cs +++ b/Services/ConduitLLM.Gateway/Services/DiscoveryCacheWarmingService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.Json; using ConduitLLM.Configuration; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Services; @@ -39,7 +40,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Wait for the application to fully start using configurable delay var startupDelay = TimeSpan.FromSeconds(_options.WarmupStartupDelaySeconds); - _logger.LogInformation("Waiting {Seconds} seconds before starting cache warming", _options.WarmupStartupDelaySeconds); + _logger.LogDebug("Waiting {Seconds} seconds before starting cache warming", _options.WarmupStartupDelaySeconds); await Task.Delay(startupDelay, stoppingToken); // Try to acquire distributed lock if enabled @@ -53,7 +54,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (lockService != null) { - _logger.LogInformation("Attempting to acquire distributed lock for cache warming"); + _logger.LogDebug("Attempting to acquire distributed lock for cache warming"); var lockTimeout = TimeSpan.FromSeconds(_options.DistributedLockTimeoutSeconds); distributedLock = await lockService.AcquireLockWithRetryAsync( @@ -65,7 +66,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (distributedLock != null) { - _logger.LogInformation("Acquired distributed lock for cache warming"); + _logger.LogDebug("Acquired distributed lock for cache warming"); } } else @@ -164,7 +165,7 @@ private async Task WarmCacheForCapability( .Where(m => m.IsEnabled && m.Provider != null && m.Provider.IsEnabled) .ToListAsync(cancellationToken); - var models = new List(); + var models = new List(); foreach (var mapping in modelMappings) { @@ -202,7 +203,8 @@ private async Task WarmCacheForCapability( var maxInputTokens = mapping.ModelProviderTypeAssociation.MaxInputTokens ?? caps.MaxInputTokens ?? 0; var maxOutputTokens = mapping.ModelProviderTypeAssociation.MaxOutputTokens ?? caps.MaxOutputTokens ?? 0; - models.Add(new + // Serialize to JsonElement for cache-safe storage (anonymous objects can't round-trip through JSON deserialization) + models.Add(JsonSerializer.SerializeToElement(new { // Identity id = mapping.ModelAlias, @@ -236,7 +238,7 @@ private async Task WarmCacheForCapability( max_tokens = maxInputTokens + maxOutputTokens, max_output_tokens = maxOutputTokens } - }); + })); } // Cache the results diff --git a/Services/ConduitLLM.Gateway/Services/DistributedAlertManagementService.cs b/Services/ConduitLLM.Gateway/Services/DistributedAlertManagementService.cs index 6fb3c10e0..80c3427c0 100644 --- a/Services/ConduitLLM.Gateway/Services/DistributedAlertManagementService.cs +++ b/Services/ConduitLLM.Gateway/Services/DistributedAlertManagementService.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.SignalR; using StackExchange.Redis; using ConduitLLM.Configuration.DTOs.HealthMonitoring; +using ConduitLLM.Core.Extensions; using ConduitLLM.Gateway.Hubs; using ConduitLLM.Gateway.Interfaces; @@ -148,21 +149,21 @@ public async Task TriggerAlertAsync(HealthAlert alert) // Check if alert should be suppressed if (await IsAlertSuppressedAsync(alert)) { - _logger.LogDebug("Alert suppressed: {AlertTitle}", alert.Title); + _logger.LogDebug("Alert suppressed: {AlertTitle}", LoggingSanitizer.S(alert.Title)); return; } // Generate alert fingerprint for deduplication var fingerprint = GenerateAlertFingerprint(alert); var lockKey = $"{AlertLockPrefix}:{fingerprint}"; - + // Use distributed lock to prevent duplicate alerts from multiple instances var lockValue = Guid.NewGuid().ToString(); var lockAcquired = await _database.StringSetAsync(lockKey, lockValue, TimeSpan.FromMinutes(5), false, When.NotExists, CommandFlags.None); - + if (!lockAcquired) { - _logger.LogDebug("Alert already being processed by another instance: {AlertTitle}", alert.Title); + _logger.LogDebug("Alert already being processed by another instance: {AlertTitle}", LoggingSanitizer.S(alert.Title)); return; } @@ -307,7 +308,7 @@ public async Task SaveAlertRuleAsync(AlertRule rule) rule.Id = rule.Id ?? Guid.NewGuid().ToString(); await _database.HashSetAsync(AlertRulesKey, rule.Id, JsonSerializer.Serialize(rule)); - _logger.LogInformation("Alert rule {RuleId} saved: {RuleName}", rule.Id, rule.Name); + _logger.LogInformation("Alert rule {RuleId} saved: {RuleName}", rule.Id, LoggingSanitizer.S(rule.Name)); return rule; } @@ -399,7 +400,7 @@ public async Task> GetActiveSuppressionsAsync() public async Task> GetActiveInstancesAsync() { var pattern = $"{InstancesSetKey}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); var instances = new List(); @@ -451,7 +452,7 @@ public async IAsyncEnumerable GetAlertStreamAsync( } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to deserialize alert for fingerprint check"); + _logger.LogWarning(ex, "Failed to deserialize alert for fingerprint check: {Fingerprint}", fingerprint); } } @@ -595,7 +596,7 @@ private async Task ProcessAlertStreamAsync() } catch (Exception ex) { - _logger.LogError(ex, "Error processing alert stream"); + _logger.LogError(ex, "Error processing alert stream, backing off for 5s"); await Task.Delay(5000); // Back off on errors } } @@ -633,7 +634,7 @@ private async Task CleanupExpiredDataAsync() // Clean up old alert history var historyPattern = $"{AlertHistoryPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var historyKeys = server.Keys(pattern: historyPattern); foreach (var key in historyKeys) diff --git a/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Aggregation.cs b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Aggregation.cs new file mode 100644 index 000000000..776226765 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Aggregation.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using ConduitLLM.Configuration.DTOs.HealthMonitoring; +using ConduitLLM.Core.Extensions; +using StackExchange.Redis; + +namespace ConduitLLM.Gateway.Services +{ + public partial class DistributedPerformanceMonitoringService + { + private async Task AggregateMetricsAsync() + { + try + { + var metrics = await GetAggregatedMetricsAsync(); + + // Store aggregated metrics snapshot + var key = $"{MetricsPrefix}_snapshot_{DateTime.UtcNow:yyyyMMddHHmmss}"; + await _database.StringSetAsync(key, JsonSerializer.Serialize(metrics), TimeSpan.FromHours(24)); + + _logger.LogDebug("Aggregated performance metrics: {RequestsPerSecond} req/s, {ErrorRate}% errors, {AvgResponse}ms avg response", + metrics.RequestsPerSecond, metrics.ErrorRatePercent, metrics.AverageResponseTimeMs); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error aggregating performance metrics"); + } + } + + public async Task GetAggregatedMetricsAsync() + { + await _aggregationSemaphore.WaitAsync(); + try + { + var now = DateTime.UtcNow; + var windowStart = now.AddSeconds(-_options.MetricsWindowSeconds); + + // Get recent requests from stream + var requestEntries = await _database.StreamRangeAsync(RequestsStreamKey, + $"{windowStart.Ticks}", "+", _options.MaxMetricsRetention); + + var recentRequests = requestEntries + .Select(entry => JsonSerializer.Deserialize>(entry.Values[0].Value.ToString())) + .Where(r => r != null && long.Parse(r.GetValueOrDefault("Timestamp", "0")?.ToString() ?? "0") > windowStart.Ticks) + .ToList(); + + var requestCount = recentRequests.Count; + var successCount = recentRequests.Count(r => r != null && bool.Parse(r.GetValueOrDefault("IsSuccess", "false")?.ToString() ?? "false")); + var errorRate = requestCount > 0 ? ((double)(requestCount - successCount) / requestCount) * 100 : 0; + + var responseTimes = recentRequests + .Where(r => r != null) + .Select(r => double.Parse(r!.GetValueOrDefault("ResponseTimeMs", "0")?.ToString() ?? "0")) + .OrderBy(t => t) + .ToList(); + + var metrics = new PerformanceMetrics + { + RequestsPerSecond = requestCount / (double)_options.MetricsWindowSeconds, + ErrorRatePercent = errorRate, + ActiveRequests = 0 // Would need separate tracking + }; + + if (responseTimes.Count > 0) + { + metrics.AverageResponseTimeMs = responseTimes.Average(); + metrics.P95ResponseTimeMs = GetPercentile(responseTimes, 0.95); + metrics.P99ResponseTimeMs = GetPercentile(responseTimes, 0.99); + } + + return metrics; + } + finally + { + _aggregationSemaphore.Release(); + } + } + + public async Task> GetAggregatedEndpointMetricsAsync() + { + var pattern = $"{EndpointMetricsPrefix}:*"; + var server = _database.Multiplexer.GetPrimaryServer(); + var keys = server.Keys(pattern: pattern); + + var endpointMetrics = new Dictionary(); + + foreach (var key in keys) + { + var hash = await _database.HashGetAllAsync(key); + if (hash.Length == 0) continue; + + var hashDict = hash.ToDictionary(x => x.Name, x => x.Value); + + var endpoint = hashDict.GetValueOrDefault("endpoint", "").ToString(); + var totalRequests = (int)hashDict.GetValueOrDefault("total_requests", 0); + var successfulRequests = (int)hashDict.GetValueOrDefault("successful_requests", 0); + var totalResponseTime = (double)hashDict.GetValueOrDefault("total_response_time", 0); + var maxResponseTime = (double)hashDict.GetValueOrDefault("max_response_time", 0); + var minResponseTime = (double)hashDict.GetValueOrDefault("min_response_time", 0); + var lastUpdatedTicks = (long)hashDict.GetValueOrDefault("last_updated", 0); + + endpointMetrics[endpoint ?? ""] = new ConduitLLM.Configuration.DTOs.HealthMonitoring.EndpointMetrics + { + Endpoint = endpoint ?? "", + TotalRequests = totalRequests, + SuccessfulRequests = successfulRequests, + TotalResponseTime = totalResponseTime, + MaxResponseTime = maxResponseTime, + MinResponseTime = minResponseTime, + LastUpdated = new DateTime(lastUpdatedTicks) + }; + } + + return endpointMetrics; + } + + private static double GetPercentile(List sortedValues, double percentile) + { + if (sortedValues.Count == 0) return 0; + + var index = (int)Math.Ceiling(percentile * sortedValues.Count) - 1; + return sortedValues[Math.Max(0, Math.Min(index, sortedValues.Count - 1))]; + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Alerting.cs b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Alerting.cs new file mode 100644 index 000000000..218675472 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Alerting.cs @@ -0,0 +1,258 @@ +using System.Text.Json; +using ConduitLLM.Configuration.DTOs.HealthMonitoring; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Gateway.Interfaces; +using StackExchange.Redis; + +namespace ConduitLLM.Gateway.Services +{ + public partial class DistributedPerformanceMonitoringService + { + private async Task CheckThresholdsAsync() + { + try + { + var metrics = await GetAggregatedMetricsAsync(); + + // Check response time thresholds + if (metrics.P99ResponseTimeMs > _options.ResponseTimeP99CriticalMs) + { + await TriggerPerformanceAlertAsync( + AlertSeverity.Critical, + "Critical Response Time", + $"P99 response time is {metrics.P99ResponseTimeMs:F0}ms (threshold: {_options.ResponseTimeP99CriticalMs}ms)", + metrics); + } + else if (metrics.P95ResponseTimeMs > _options.ResponseTimeP95WarningMs) + { + await TriggerPerformanceAlertAsync( + AlertSeverity.Warning, + "High Response Time", + $"P95 response time is {metrics.P95ResponseTimeMs:F0}ms (threshold: {_options.ResponseTimeP95WarningMs}ms)", + metrics); + } + + // Check error rate thresholds + if (metrics.ErrorRatePercent > _options.ErrorRateCriticalPercent) + { + await TriggerPerformanceAlertAsync( + AlertSeverity.Critical, + "Critical Error Rate", + $"Error rate is {metrics.ErrorRatePercent:F1}% (threshold: {_options.ErrorRateCriticalPercent}%)", + metrics); + } + else if (metrics.ErrorRatePercent > _options.ErrorRateWarningPercent) + { + await TriggerPerformanceAlertAsync( + AlertSeverity.Warning, + "High Error Rate", + $"Error rate is {metrics.ErrorRatePercent:F1}% (threshold: {_options.ErrorRateWarningPercent}%)", + metrics); + } + + // Check request rate thresholds + if (metrics.RequestsPerSecond > _options.RequestRateHighThreshold) + { + await TriggerPerformanceAlertAsync( + AlertSeverity.Warning, + "High Request Rate", + $"Request rate is {metrics.RequestsPerSecond:F1} req/s (threshold: {_options.RequestRateHighThreshold} req/s)", + metrics); + } + + // Check database and cache performance + await CheckDatabasePerformanceAsync(); + await CheckCachePerformanceAsync(); + await CheckConnectionPoolsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking performance thresholds"); + } + } + + private async Task CheckDatabasePerformanceAsync() + { + var windowStart = DateTime.UtcNow.AddSeconds(-_options.MetricsWindowSeconds); + var entries = await _database.StreamRangeAsync(DatabaseOpsStreamKey, + $"{windowStart.Ticks}", "+", _options.MaxMetricsRetention); + + var recentQueries = entries + .Select(entry => JsonSerializer.Deserialize>(entry.Values[0].Value.ToString())) + .Where(q => q != null && long.Parse(q.GetValueOrDefault("Timestamp", "0")?.ToString() ?? "0") > windowStart.Ticks) + .ToList(); + + if (recentQueries.Count == 0) return; + + var slowQueries = recentQueries + .Where(q => q != null && double.Parse(q.GetValueOrDefault("ExecutionTimeMs", "0")?.ToString() ?? "0") > _options.DatabaseSlowQueryThresholdMs) + .ToList(); + + if (slowQueries.Count > _options.DatabaseSlowQueryCountThreshold) + { + var avgSlowQueryTime = slowQueries.Average(q => q != null ? double.Parse(q.GetValueOrDefault("ExecutionTimeMs", "0")?.ToString() ?? "0") : 0); + await _alertManagementService.TriggerAlertAsync(new HealthAlert + { + Severity = AlertSeverity.Warning, + Type = AlertType.PerformanceDegradation, + Component = "Database", + Title = "High Number of Slow Queries", + Message = $"Detected {slowQueries.Count} slow queries across all instances in the last {_options.MetricsWindowSeconds} seconds. Average execution time: {avgSlowQueryTime:F0}ms", + Context = new Dictionary + { + ["slowQueryCount"] = slowQueries.Count, + ["averageExecutionTime"] = avgSlowQueryTime, + ["threshold"] = _options.DatabaseSlowQueryThresholdMs, + ["detectedByInstance"] = InstanceId, + ["operations"] = slowQueries.Where(q => q != null).GroupBy(q => q!.GetValueOrDefault("Operation", "")?.ToString() ?? "") + .Select(g => new { Operation = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(5) + .ToList() + }, + SuggestedActions = new List + { + "Review slow query log", + "Check for missing database indexes", + "Analyze query execution plans", + "Consider query optimization" + } + }); + } + } + + private async Task CheckCachePerformanceAsync() + { + var pattern = $"{CacheMetricsPrefix}:*"; + var server = _database.Multiplexer.GetPrimaryServer(); + var keys = server.Keys(pattern: pattern); + + foreach (var key in keys) + { + var hash = await _database.HashGetAllAsync(key); + if (hash.Length == 0) continue; + + var hashDict = hash.ToDictionary(x => x.Name, x => x.Value); + var operation = hashDict.GetValueOrDefault("operation", ""); + var totalRequests = (int)hashDict.GetValueOrDefault("total_requests", 0); + var hits = (int)hashDict.GetValueOrDefault("hits", 0); + + var hitRate = totalRequests > 0 ? (double)hits / totalRequests * 100 : 100; + + if (hitRate < _options.CacheHitRateLowThreshold) + { + await _alertManagementService.TriggerAlertAsync(new HealthAlert + { + Severity = AlertSeverity.Warning, + Type = AlertType.PerformanceDegradation, + Component = "Cache", + Title = $"Low Cache Hit Rate for {operation}", + Message = $"Cache hit rate is {hitRate:F1}% (threshold: {_options.CacheHitRateLowThreshold}%)", + Context = new Dictionary + { + ["operation"] = operation, + ["hitRate"] = hitRate, + ["totalRequests"] = totalRequests, + ["hits"] = hits, + ["detectedByInstance"] = InstanceId + }, + SuggestedActions = new List + { + "Review cache eviction policies", + "Increase cache size if needed", + "Analyze cache key patterns", + "Check for cache invalidation issues" + } + }); + } + } + } + + private async Task CheckConnectionPoolsAsync() + { + var pattern = $"{ConnectionPoolMetricsPrefix}:*"; + var server = _database.Multiplexer.GetPrimaryServer(); + var keys = server.Keys(pattern: pattern); + + foreach (var key in keys) + { + var hash = await _database.HashGetAllAsync(key); + if (hash.Length == 0) continue; + + var hashDict = hash.ToDictionary(x => x.Name, x => x.Value); + var poolName = hashDict.GetValueOrDefault("pool_name", ""); + var activeConnections = (int)hashDict.GetValueOrDefault("active_connections", 0); + var idleConnections = (int)hashDict.GetValueOrDefault("idle_connections", 0); + var waitQueueLength = (int)hashDict.GetValueOrDefault("wait_queue_length", 0); + + var totalConnections = activeConnections + idleConnections; + var utilizationPercent = totalConnections > 0 ? (double)activeConnections / totalConnections * 100 : 0; + + if (utilizationPercent > _options.ConnectionPoolHighUtilizationThreshold) + { + await _alertManagementService.TriggerAlertAsync(new HealthAlert + { + Severity = AlertSeverity.Warning, + Type = AlertType.ResourceExhaustion, + Component = $"{poolName} Connection Pool", + Title = "High Connection Pool Utilization", + Message = $"Connection pool utilization is {utilizationPercent:F1}% with {waitQueueLength} requests waiting", + Context = new Dictionary + { + ["poolName"] = poolName, + ["activeConnections"] = activeConnections, + ["idleConnections"] = idleConnections, + ["waitQueueLength"] = waitQueueLength, + ["utilizationPercent"] = utilizationPercent, + ["detectedByInstance"] = InstanceId + } + }); + } + + if (waitQueueLength > _options.ConnectionPoolQueueWarningThreshold) + { + await _alertManagementService.TriggerAlertAsync(new HealthAlert + { + Severity = AlertSeverity.Error, + Type = AlertType.ResourceExhaustion, + Component = $"{poolName} Connection Pool", + Title = "Connection Pool Queue Buildup", + Message = $"Connection pool has {waitQueueLength} requests waiting in queue", + Context = new Dictionary + { + ["poolName"] = poolName, + ["waitQueueLength"] = waitQueueLength, + ["activeConnections"] = activeConnections, + ["detectedByInstance"] = InstanceId + } + }); + } + } + } + + private async Task TriggerPerformanceAlertAsync( + AlertSeverity severity, + string title, + string message, + PerformanceMetrics metrics) + { + await _alertManagementService.TriggerAlertAsync(new HealthAlert + { + Severity = severity, + Type = AlertType.PerformanceDegradation, + Component = "API Performance", + Title = title, + Message = message, + Context = new Dictionary + { + ["requestsPerSecond"] = metrics.RequestsPerSecond, + ["errorRatePercent"] = metrics.ErrorRatePercent, + ["averageResponseTime"] = metrics.AverageResponseTimeMs, + ["p95ResponseTime"] = metrics.P95ResponseTimeMs, + ["p99ResponseTime"] = metrics.P99ResponseTimeMs, + ["detectedByInstance"] = InstanceId + } + }); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Recording.cs b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Recording.cs new file mode 100644 index 000000000..61f20cbe7 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.Recording.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using StackExchange.Redis; + +namespace ConduitLLM.Gateway.Services +{ + public partial class DistributedPerformanceMonitoringService + { + public async Task RecordRequestMetricAsync(string endpoint, double responseTimeMs, bool isSuccess) + { + try + { + var metric = new + { + InstanceId, + Endpoint = endpoint, + ResponseTimeMs = responseTimeMs, + IsSuccess = isSuccess, + Timestamp = DateTime.UtcNow.Ticks + }; + + // Add to Redis stream for time-series data + await _database.StreamAddAsync(RequestsStreamKey, "data", JsonSerializer.Serialize(metric)); + + // Trim stream to keep only recent data (last hour) + await _database.StreamTrimAsync(RequestsStreamKey, _options.MaxMetricsRetention, false); + + // Update endpoint-specific aggregated metrics atomically + var endpointKey = $"{EndpointMetricsPrefix}:{endpoint}"; + var script = @" + local key = KEYS[1] + local responseTime = tonumber(ARGV[1]) + local isSuccess = ARGV[2] == 'true' + + local current = redis.call('HMGET', key, 'total_requests', 'successful_requests', 'total_response_time', 'max_response_time', 'min_response_time') + + local totalRequests = tonumber(current[1]) or 0 + local successfulRequests = tonumber(current[2]) or 0 + local totalResponseTime = tonumber(current[3]) or 0 + local maxResponseTime = tonumber(current[4]) or responseTime + local minResponseTime = tonumber(current[5]) or responseTime + + totalRequests = totalRequests + 1 + if isSuccess then + successfulRequests = successfulRequests + 1 + end + totalResponseTime = totalResponseTime + responseTime + maxResponseTime = math.max(maxResponseTime, responseTime) + minResponseTime = math.min(minResponseTime, responseTime) + + redis.call('HMSET', key, + 'endpoint', ARGV[3], + 'total_requests', totalRequests, + 'successful_requests', successfulRequests, + 'total_response_time', totalResponseTime, + 'max_response_time', maxResponseTime, + 'min_response_time', minResponseTime, + 'last_updated', ARGV[4] + ) + + redis.call('EXPIRE', key, 3600) + + return totalRequests + "; + + await _database.ScriptEvaluateAsync(script, new RedisKey[] { endpointKey }, + new RedisValue[] { responseTimeMs, isSuccess.ToString(), endpoint, DateTime.UtcNow.Ticks }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record request metric for endpoint {Endpoint}", endpoint); + } + } + + public async Task RecordDatabaseQueryMetricAsync(string operation, double executionTimeMs) + { + try + { + var metric = new + { + InstanceId, + Operation = operation, + ExecutionTimeMs = executionTimeMs, + Timestamp = DateTime.UtcNow.Ticks + }; + + await _database.StreamAddAsync(DatabaseOpsStreamKey, "data", JsonSerializer.Serialize(metric)); + await _database.StreamTrimAsync(DatabaseOpsStreamKey, _options.MaxMetricsRetention, false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record database query metric for operation {Operation}", operation); + } + } + + public async Task RecordCacheMetricAsync(string operation, bool isHit) + { + var cacheKey = $"{CacheMetricsPrefix}:{operation}"; + var script = @" + local key = KEYS[1] + local isHit = ARGV[1] == 'true' + + local current = redis.call('HMGET', key, 'total_requests', 'hits') + local totalRequests = tonumber(current[1]) or 0 + local hits = tonumber(current[2]) or 0 + + totalRequests = totalRequests + 1 + if isHit then + hits = hits + 1 + end + + redis.call('HMSET', key, + 'operation', ARGV[2], + 'total_requests', totalRequests, + 'hits', hits, + 'last_updated', ARGV[3] + ) + + redis.call('EXPIRE', key, 3600) + return totalRequests + "; + + try + { + await _database.ScriptEvaluateAsync(script, new RedisKey[] { cacheKey }, + new RedisValue[] { isHit.ToString(), operation, DateTime.UtcNow.Ticks }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record cache metric for operation {Operation}", operation); + } + } + + public async Task RecordConnectionPoolMetricAsync(string poolName, int active, int idle, int waitQueue) + { + var poolKey = $"{ConnectionPoolMetricsPrefix}:{poolName}"; + var data = new Dictionary + { + ["pool_name"] = poolName, + ["active_connections"] = active, + ["idle_connections"] = idle, + ["wait_queue_length"] = waitQueue, + ["last_updated"] = DateTime.UtcNow.Ticks, + ["instance_id"] = InstanceId + }; + + try + { + // Get current max_active to update it if needed + var currentMaxActive = await _database.HashGetAsync(poolKey, "max_active"); + var maxActive = Math.Max(active, (int)(currentMaxActive.HasValue ? currentMaxActive : 0)); + data["max_active"] = maxActive; + + await _database.HashSetAsync(poolKey, data.Select(kvp => new HashEntry(kvp.Key, kvp.Value)).ToArray()); + await _database.KeyExpireAsync(poolKey, TimeSpan.FromMinutes(10)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record connection pool metric for pool {PoolName}", poolName); + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.cs b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.cs index a25b01178..64ec9198a 100644 --- a/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.cs +++ b/Services/ConduitLLM.Gateway/Services/DistributedPerformanceMonitoringService.cs @@ -1,10 +1,9 @@ -using System.Collections.Concurrent; using System.Text.Json; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using StackExchange.Redis; using ConduitLLM.Configuration.DTOs.HealthMonitoring; using ConduitLLM.Configuration.Options; +using ConduitLLM.Core.Extensions; using ConduitLLM.Gateway.Interfaces; namespace ConduitLLM.Gateway.Services @@ -12,15 +11,15 @@ namespace ConduitLLM.Gateway.Services /// /// Distributed performance monitoring service that stores metrics in Redis for multi-instance consistency /// - public class DistributedPerformanceMonitoringService : IDistributedPerformanceMonitoringService, IHostedService, IDisposable + public partial class DistributedPerformanceMonitoringService : IDistributedPerformanceMonitoringService, IHostedService, IDisposable { private readonly IDatabase _database; private readonly IDistributedAlertManagementService _alertManagementService; private readonly ILogger _logger; private readonly PerformanceMonitoringOptions _options; - + public string InstanceId { get; } - + // Redis keys private const string MetricsPrefix = "perf_metrics"; private const string EndpointMetricsPrefix = "endpoint_metrics"; @@ -29,7 +28,7 @@ public class DistributedPerformanceMonitoringService : IDistributedPerformanceMo private const string InstancesSetKey = "perf_monitoring_instances"; private const string RequestsStreamKey = "request_metrics_stream"; private const string DatabaseOpsStreamKey = "database_ops_stream"; - + private Timer? _metricsAggregationTimer; private Timer? _thresholdCheckTimer; private Timer? _heartbeatTimer; @@ -52,7 +51,7 @@ public DistributedPerformanceMonitoringService( public async Task StartAsync(CancellationToken cancellationToken) { await RegisterInstanceAsync(); - + _logger.LogInformation("Distributed performance monitoring service started with instance ID: {InstanceId}", InstanceId); // Start heartbeat timer (every 30 seconds) @@ -121,256 +120,15 @@ public async Task UpdateHeartbeatAsync() } } - public async Task RecordRequestMetricAsync(string endpoint, double responseTimeMs, bool isSuccess) - { - try - { - var metric = new - { - InstanceId, - Endpoint = endpoint, - ResponseTimeMs = responseTimeMs, - IsSuccess = isSuccess, - Timestamp = DateTime.UtcNow.Ticks - }; - - // Add to Redis stream for time-series data - await _database.StreamAddAsync(RequestsStreamKey, "data", JsonSerializer.Serialize(metric)); - - // Trim stream to keep only recent data (last hour) - await _database.StreamTrimAsync(RequestsStreamKey, _options.MaxMetricsRetention, false); - - // Update endpoint-specific aggregated metrics atomically - var endpointKey = $"{EndpointMetricsPrefix}:{endpoint}"; - var script = @" - local key = KEYS[1] - local responseTime = tonumber(ARGV[1]) - local isSuccess = ARGV[2] == 'true' - - local current = redis.call('HMGET', key, 'total_requests', 'successful_requests', 'total_response_time', 'max_response_time', 'min_response_time') - - local totalRequests = tonumber(current[1]) or 0 - local successfulRequests = tonumber(current[2]) or 0 - local totalResponseTime = tonumber(current[3]) or 0 - local maxResponseTime = tonumber(current[4]) or responseTime - local minResponseTime = tonumber(current[5]) or responseTime - - totalRequests = totalRequests + 1 - if isSuccess then - successfulRequests = successfulRequests + 1 - end - totalResponseTime = totalResponseTime + responseTime - maxResponseTime = math.max(maxResponseTime, responseTime) - minResponseTime = math.min(minResponseTime, responseTime) - - redis.call('HMSET', key, - 'endpoint', ARGV[3], - 'total_requests', totalRequests, - 'successful_requests', successfulRequests, - 'total_response_time', totalResponseTime, - 'max_response_time', maxResponseTime, - 'min_response_time', minResponseTime, - 'last_updated', ARGV[4] - ) - - redis.call('EXPIRE', key, 3600) - - return totalRequests - "; - - await _database.ScriptEvaluateAsync(script, new RedisKey[] { endpointKey }, - new RedisValue[] { responseTimeMs, isSuccess.ToString(), endpoint, DateTime.UtcNow.Ticks }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to record request metric for endpoint {Endpoint}", endpoint); - } - } - - public async Task RecordDatabaseQueryMetricAsync(string operation, double executionTimeMs) - { - try - { - var metric = new - { - InstanceId, - Operation = operation, - ExecutionTimeMs = executionTimeMs, - Timestamp = DateTime.UtcNow.Ticks - }; - - await _database.StreamAddAsync(DatabaseOpsStreamKey, "data", JsonSerializer.Serialize(metric)); - await _database.StreamTrimAsync(DatabaseOpsStreamKey, _options.MaxMetricsRetention, false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to record database query metric for operation {Operation}", operation); - } - } - - public async Task RecordCacheMetricAsync(string operation, bool isHit) - { - var cacheKey = $"{CacheMetricsPrefix}:{operation}"; - var script = @" - local key = KEYS[1] - local isHit = ARGV[1] == 'true' - - local current = redis.call('HMGET', key, 'total_requests', 'hits') - local totalRequests = tonumber(current[1]) or 0 - local hits = tonumber(current[2]) or 0 - - totalRequests = totalRequests + 1 - if isHit then - hits = hits + 1 - end - - redis.call('HMSET', key, - 'operation', ARGV[2], - 'total_requests', totalRequests, - 'hits', hits, - 'last_updated', ARGV[3] - ) - - redis.call('EXPIRE', key, 3600) - return totalRequests - "; - - try - { - await _database.ScriptEvaluateAsync(script, new RedisKey[] { cacheKey }, - new RedisValue[] { isHit.ToString(), operation, DateTime.UtcNow.Ticks }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to record cache metric for operation {Operation}", operation); - } - } - - public async Task RecordConnectionPoolMetricAsync(string poolName, int active, int idle, int waitQueue) - { - var poolKey = $"{ConnectionPoolMetricsPrefix}:{poolName}"; - var data = new Dictionary - { - ["pool_name"] = poolName, - ["active_connections"] = active, - ["idle_connections"] = idle, - ["wait_queue_length"] = waitQueue, - ["last_updated"] = DateTime.UtcNow.Ticks, - ["instance_id"] = InstanceId - }; - - try - { - // Get current max_active to update it if needed - var currentMaxActive = await _database.HashGetAsync(poolKey, "max_active"); - var maxActive = Math.Max(active, (int)(currentMaxActive.HasValue ? currentMaxActive : 0)); - data["max_active"] = maxActive; - - await _database.HashSetAsync(poolKey, data.Select(kvp => new HashEntry(kvp.Key, kvp.Value)).ToArray()); - await _database.KeyExpireAsync(poolKey, TimeSpan.FromMinutes(10)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to record connection pool metric for pool {PoolName}", poolName); - } - } - - public async Task GetAggregatedMetricsAsync() - { - await _aggregationSemaphore.WaitAsync(); - try - { - var now = DateTime.UtcNow; - var windowStart = now.AddSeconds(-_options.MetricsWindowSeconds); - - // Get recent requests from stream - var requestEntries = await _database.StreamRangeAsync(RequestsStreamKey, - $"{windowStart.Ticks}", "+", _options.MaxMetricsRetention); - - var recentRequests = requestEntries - .Select(entry => JsonSerializer.Deserialize>(entry.Values[0].Value.ToString())) - .Where(r => r != null && long.Parse(r.GetValueOrDefault("Timestamp", "0")?.ToString() ?? "0") > windowStart.Ticks) - .ToList(); - - var requestCount = recentRequests.Count; - var successCount = recentRequests.Count(r => r != null && bool.Parse(r.GetValueOrDefault("IsSuccess", "false")?.ToString() ?? "false")); - var errorRate = requestCount > 0 ? ((double)(requestCount - successCount) / requestCount) * 100 : 0; - - var responseTimes = recentRequests - .Where(r => r != null) - .Select(r => double.Parse(r!.GetValueOrDefault("ResponseTimeMs", "0")?.ToString() ?? "0")) - .OrderBy(t => t) - .ToList(); - - var metrics = new PerformanceMetrics - { - RequestsPerSecond = requestCount / (double)_options.MetricsWindowSeconds, - ErrorRatePercent = errorRate, - ActiveRequests = 0 // Would need separate tracking - }; - - if (responseTimes.Count > 0) - { - metrics.AverageResponseTimeMs = responseTimes.Average(); - metrics.P95ResponseTimeMs = GetPercentile(responseTimes, 0.95); - metrics.P99ResponseTimeMs = GetPercentile(responseTimes, 0.99); - } - - return metrics; - } - finally - { - _aggregationSemaphore.Release(); - } - } - - public async Task> GetAggregatedEndpointMetricsAsync() - { - var pattern = $"{EndpointMetricsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); - var keys = server.Keys(pattern: pattern); - - var endpointMetrics = new Dictionary(); - - foreach (var key in keys) - { - var hash = await _database.HashGetAllAsync(key); - if (hash.Length == 0) continue; - - var hashDict = hash.ToDictionary(x => x.Name, x => x.Value); - - var endpoint = hashDict.GetValueOrDefault("endpoint", "").ToString(); - var totalRequests = (int)hashDict.GetValueOrDefault("total_requests", 0); - var successfulRequests = (int)hashDict.GetValueOrDefault("successful_requests", 0); - var totalResponseTime = (double)hashDict.GetValueOrDefault("total_response_time", 0); - var maxResponseTime = (double)hashDict.GetValueOrDefault("max_response_time", 0); - var minResponseTime = (double)hashDict.GetValueOrDefault("min_response_time", 0); - var lastUpdatedTicks = (long)hashDict.GetValueOrDefault("last_updated", 0); - - endpointMetrics[endpoint ?? ""] = new ConduitLLM.Configuration.DTOs.HealthMonitoring.EndpointMetrics - { - Endpoint = endpoint ?? "", - TotalRequests = totalRequests, - SuccessfulRequests = successfulRequests, - TotalResponseTime = totalResponseTime, - MaxResponseTime = maxResponseTime, - MinResponseTime = minResponseTime, - LastUpdated = new DateTime(lastUpdatedTicks) - }; - } - - return endpointMetrics; - } - public async Task> GetActiveInstancesAsync() { var pattern = $"{InstancesSetKey}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); - + var instances = new List(); var cutoffTime = DateTime.UtcNow.AddMinutes(-1); // Consider instances active if heartbeat within last minute - + foreach (var key in keys) { var lastHeartbeat = await _database.HashGetAsync(key, "last_heartbeat"); @@ -384,306 +142,44 @@ public async Task> GetActiveInstancesAsync() } } } - - return instances; - } - - private async Task AggregateMetricsAsync() - { - try - { - var metrics = await GetAggregatedMetricsAsync(); - - // Store aggregated metrics snapshot - var key = $"{MetricsPrefix}_snapshot_{DateTime.UtcNow:yyyyMMddHHmmss}"; - await _database.StringSetAsync(key, JsonSerializer.Serialize(metrics), TimeSpan.FromHours(24)); - - _logger.LogDebug("Aggregated performance metrics: {RequestsPerSecond} req/s, {ErrorRate}% errors, {AvgResponse}ms avg response", - metrics.RequestsPerSecond, metrics.ErrorRatePercent, metrics.AverageResponseTimeMs); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error aggregating performance metrics"); - } - } - - private async Task CheckThresholdsAsync() - { - try - { - var metrics = await GetAggregatedMetricsAsync(); - - // Check response time thresholds - if (metrics.P99ResponseTimeMs > _options.ResponseTimeP99CriticalMs) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Critical, - "Critical Response Time", - $"P99 response time is {metrics.P99ResponseTimeMs:F0}ms (threshold: {_options.ResponseTimeP99CriticalMs}ms)", - metrics); - } - else if (metrics.P95ResponseTimeMs > _options.ResponseTimeP95WarningMs) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Warning, - "High Response Time", - $"P95 response time is {metrics.P95ResponseTimeMs:F0}ms (threshold: {_options.ResponseTimeP95WarningMs}ms)", - metrics); - } - - // Check error rate thresholds - if (metrics.ErrorRatePercent > _options.ErrorRateCriticalPercent) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Critical, - "Critical Error Rate", - $"Error rate is {metrics.ErrorRatePercent:F1}% (threshold: {_options.ErrorRateCriticalPercent}%)", - metrics); - } - else if (metrics.ErrorRatePercent > _options.ErrorRateWarningPercent) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Warning, - "High Error Rate", - $"Error rate is {metrics.ErrorRatePercent:F1}% (threshold: {_options.ErrorRateWarningPercent}%)", - metrics); - } - - // Check request rate thresholds - if (metrics.RequestsPerSecond > _options.RequestRateHighThreshold) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Warning, - "High Request Rate", - $"Request rate is {metrics.RequestsPerSecond:F1} req/s (threshold: {_options.RequestRateHighThreshold} req/s)", - metrics); - } - - // Check database and cache performance - await CheckDatabasePerformanceAsync(); - await CheckCachePerformanceAsync(); - await CheckConnectionPoolsAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking performance thresholds"); - } - } - - private async Task CheckDatabasePerformanceAsync() - { - var windowStart = DateTime.UtcNow.AddSeconds(-_options.MetricsWindowSeconds); - var entries = await _database.StreamRangeAsync(DatabaseOpsStreamKey, - $"{windowStart.Ticks}", "+", _options.MaxMetricsRetention); - - var recentQueries = entries - .Select(entry => JsonSerializer.Deserialize>(entry.Values[0].Value.ToString())) - .Where(q => q != null && long.Parse(q.GetValueOrDefault("Timestamp", "0")?.ToString() ?? "0") > windowStart.Ticks) - .ToList(); - - if (recentQueries.Count == 0) return; - - var slowQueries = recentQueries - .Where(q => q != null && double.Parse(q.GetValueOrDefault("ExecutionTimeMs", "0")?.ToString() ?? "0") > _options.DatabaseSlowQueryThresholdMs) - .ToList(); - - if (slowQueries.Count > _options.DatabaseSlowQueryCountThreshold) - { - var avgSlowQueryTime = slowQueries.Average(q => q != null ? double.Parse(q.GetValueOrDefault("ExecutionTimeMs", "0")?.ToString() ?? "0") : 0); - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.PerformanceDegradation, - Component = "Database", - Title = "High Number of Slow Queries", - Message = $"Detected {slowQueries.Count} slow queries across all instances in the last {_options.MetricsWindowSeconds} seconds. Average execution time: {avgSlowQueryTime:F0}ms", - Context = new Dictionary - { - ["slowQueryCount"] = slowQueries.Count, - ["averageExecutionTime"] = avgSlowQueryTime, - ["threshold"] = _options.DatabaseSlowQueryThresholdMs, - ["detectedByInstance"] = InstanceId, - ["operations"] = slowQueries.Where(q => q != null).GroupBy(q => q!.GetValueOrDefault("Operation", "")?.ToString() ?? "") - .Select(g => new { Operation = g.Key, Count = g.Count() }) - .OrderByDescending(x => x.Count) - .Take(5) - .ToList() - }, - SuggestedActions = new List - { - "Review slow query log", - "Check for missing database indexes", - "Analyze query execution plans", - "Consider query optimization" - } - }); - } - } - - private async Task CheckCachePerformanceAsync() - { - var pattern = $"{CacheMetricsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); - var keys = server.Keys(pattern: pattern); - - foreach (var key in keys) - { - var hash = await _database.HashGetAllAsync(key); - if (hash.Length == 0) continue; - - var hashDict = hash.ToDictionary(x => x.Name, x => x.Value); - var operation = hashDict.GetValueOrDefault("operation", ""); - var totalRequests = (int)hashDict.GetValueOrDefault("total_requests", 0); - var hits = (int)hashDict.GetValueOrDefault("hits", 0); - - var hitRate = totalRequests > 0 ? (double)hits / totalRequests * 100 : 100; - - if (hitRate < _options.CacheHitRateLowThreshold) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.PerformanceDegradation, - Component = "Cache", - Title = $"Low Cache Hit Rate for {operation}", - Message = $"Cache hit rate is {hitRate:F1}% (threshold: {_options.CacheHitRateLowThreshold}%)", - Context = new Dictionary - { - ["operation"] = operation, - ["hitRate"] = hitRate, - ["totalRequests"] = totalRequests, - ["hits"] = hits, - ["detectedByInstance"] = InstanceId - }, - SuggestedActions = new List - { - "Review cache eviction policies", - "Increase cache size if needed", - "Analyze cache key patterns", - "Check for cache invalidation issues" - } - }); - } - } - } - - private async Task CheckConnectionPoolsAsync() - { - var pattern = $"{ConnectionPoolMetricsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); - var keys = server.Keys(pattern: pattern); - - foreach (var key in keys) - { - var hash = await _database.HashGetAllAsync(key); - if (hash.Length == 0) continue; - - var hashDict = hash.ToDictionary(x => x.Name, x => x.Value); - var poolName = hashDict.GetValueOrDefault("pool_name", ""); - var activeConnections = (int)hashDict.GetValueOrDefault("active_connections", 0); - var idleConnections = (int)hashDict.GetValueOrDefault("idle_connections", 0); - var waitQueueLength = (int)hashDict.GetValueOrDefault("wait_queue_length", 0); - var totalConnections = activeConnections + idleConnections; - var utilizationPercent = totalConnections > 0 ? (double)activeConnections / totalConnections * 100 : 0; - - if (utilizationPercent > _options.ConnectionPoolHighUtilizationThreshold) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.ResourceExhaustion, - Component = $"{poolName} Connection Pool", - Title = "High Connection Pool Utilization", - Message = $"Connection pool utilization is {utilizationPercent:F1}% with {waitQueueLength} requests waiting", - Context = new Dictionary - { - ["poolName"] = poolName, - ["activeConnections"] = activeConnections, - ["idleConnections"] = idleConnections, - ["waitQueueLength"] = waitQueueLength, - ["utilizationPercent"] = utilizationPercent, - ["detectedByInstance"] = InstanceId - } - }); - } - - if (waitQueueLength > _options.ConnectionPoolQueueWarningThreshold) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Error, - Type = AlertType.ResourceExhaustion, - Component = $"{poolName} Connection Pool", - Title = "Connection Pool Queue Buildup", - Message = $"Connection pool has {waitQueueLength} requests waiting in queue", - Context = new Dictionary - { - ["poolName"] = poolName, - ["waitQueueLength"] = waitQueueLength, - ["activeConnections"] = activeConnections, - ["detectedByInstance"] = InstanceId - } - }); - } - } + return instances; } - private async Task TriggerPerformanceAlertAsync( - AlertSeverity severity, - string title, - string message, - PerformanceMetrics metrics) + public void RecordRequestMetric(string endpoint, double responseTimeMs, bool isSuccess) { - await _alertManagementService.TriggerAlertAsync(new HealthAlert + _ = Task.Run(async () => { - Severity = severity, - Type = AlertType.PerformanceDegradation, - Component = "API Performance", - Title = title, - Message = message, - Context = new Dictionary - { - ["requestsPerSecond"] = metrics.RequestsPerSecond, - ["errorRatePercent"] = metrics.ErrorRatePercent, - ["averageResponseTime"] = metrics.AverageResponseTimeMs, - ["p95ResponseTime"] = metrics.P95ResponseTimeMs, - ["p99ResponseTime"] = metrics.P99ResponseTimeMs, - ["detectedByInstance"] = InstanceId - } + try { await RecordRequestMetricAsync(endpoint, responseTimeMs, isSuccess); } + catch (Exception ex) { _logger.LogError(ex, "Error recording request metric for {Endpoint}", endpoint); } }); } - private static double GetPercentile(List sortedValues, double percentile) - { - if (sortedValues.Count == 0) return 0; - - var index = (int)Math.Ceiling(percentile * sortedValues.Count) - 1; - return sortedValues[Math.Max(0, Math.Min(index, sortedValues.Count - 1))]; - } - - public void RecordRequestMetric(string endpoint, double responseTimeMs, bool isSuccess) - { - // Use fire-and-forget async call to maintain interface compatibility - _ = Task.Run(() => RecordRequestMetricAsync(endpoint, responseTimeMs, isSuccess)); - } - public void RecordDatabaseQueryMetric(string operation, double executionTimeMs) { - // Use fire-and-forget async call to maintain interface compatibility - _ = Task.Run(() => RecordDatabaseQueryMetricAsync(operation, executionTimeMs)); + _ = Task.Run(async () => + { + try { await RecordDatabaseQueryMetricAsync(operation, executionTimeMs); } + catch (Exception ex) { _logger.LogError(ex, "Error recording database query metric for {Operation}", operation); } + }); } public void RecordCacheMetric(string operation, bool isHit) { - // Use fire-and-forget async call to maintain interface compatibility - _ = Task.Run(() => RecordCacheMetricAsync(operation, isHit)); + _ = Task.Run(async () => + { + try { await RecordCacheMetricAsync(operation, isHit); } + catch (Exception ex) { _logger.LogError(ex, "Error recording cache metric for {Operation}", operation); } + }); } public void RecordConnectionPoolMetric(string poolName, int active, int idle, int waitQueue) { - // Use fire-and-forget async call to maintain interface compatibility - _ = Task.Run(() => RecordConnectionPoolMetricAsync(poolName, active, idle, waitQueue)); + _ = Task.Run(async () => + { + try { await RecordConnectionPoolMetricAsync(poolName, active, idle, waitQueue); } + catch (Exception ex) { _logger.LogError(ex, "Error recording connection pool metric for {PoolName}", poolName); } + }); } public async Task GetCurrentMetricsAsync() @@ -693,25 +189,7 @@ public async Task GetCurrentMetricsAsync() public async Task> GetEndpointMetricsAsync() { - var distributedMetrics = await GetAggregatedEndpointMetricsAsync(); - - // Convert to the base EndpointMetrics type - var result = new Dictionary(); - foreach (var kvp in distributedMetrics) - { - var dist = kvp.Value; - result[kvp.Key] = new EndpointMetrics - { - Endpoint = dist.Endpoint, - TotalRequests = dist.TotalRequests, - SuccessfulRequests = dist.SuccessfulRequests, - TotalResponseTime = dist.TotalResponseTime, - MaxResponseTime = dist.MaxResponseTime, - MinResponseTime = dist.MinResponseTime, - LastUpdated = dist.LastUpdated - }; - } - return result; + return await GetAggregatedEndpointMetricsAsync(); } public void Dispose() @@ -722,4 +200,4 @@ public void Dispose() _aggregationSemaphore?.Dispose(); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/DistributedSignalRMetricsService.cs b/Services/ConduitLLM.Gateway/Services/DistributedSignalRMetricsService.cs index 1a6638695..64458fe58 100644 --- a/Services/ConduitLLM.Gateway/Services/DistributedSignalRMetricsService.cs +++ b/Services/ConduitLLM.Gateway/Services/DistributedSignalRMetricsService.cs @@ -3,6 +3,7 @@ using StackExchange.Redis; using Prometheus; using ConduitLLM.Configuration.Options; +using ConduitLLM.Core.Extensions; using ConduitLLM.Gateway.Interfaces; namespace ConduitLLM.Gateway.Services @@ -331,7 +332,7 @@ public async Task OnTaskUnsubscribedAsync(string hubName, string taskType) public async Task GetGlobalConnectionCountAsync() { var pattern = $"{ActiveConnectionsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); var count = 0; @@ -392,7 +393,7 @@ public async Task> GetAggregatedMetricsAsync() public async Task> GetActiveInstancesAsync() { var pattern = $"{InstancesSetKey}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); var instances = new List(); @@ -445,7 +446,7 @@ private async Task CalculateDistributedMetricsAsync() private async Task UpdateVirtualKeyMetricsAsync() { var pattern = $"{VirtualKeyConnectionsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); foreach (var key in keys) @@ -485,7 +486,7 @@ private async Task>> GetConnectionDis { var distribution = new Dictionary>(); var pattern = $"{ActiveConnectionsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); foreach (var key in keys) @@ -512,7 +513,7 @@ private async Task>> GetConnectionDis } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to parse connection info for key {Key}", key); + _logger.LogWarning(ex, "Failed to parse connection info for key {Key}", LoggingSanitizer.S(key)); } } @@ -547,7 +548,7 @@ private async Task CleanupStaleConnectionsAsync() { var staleThreshold = DateTime.UtcNow.AddMinutes(-10); // 10 minutes without activity var pattern = $"{ActiveConnectionsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); var staleConnections = new List(); @@ -603,7 +604,7 @@ private async Task CleanupInstanceConnectionsAsync() { // Find all connections for this instance and clean them up var pattern = $"{ActiveConnectionsPrefix}:*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern); var instanceConnections = new List(); diff --git a/Services/ConduitLLM.Gateway/Services/EphemeralKeyService.cs b/Services/ConduitLLM.Gateway/Services/EphemeralKeyService.cs index 1d94a70cb..e5907652e 100644 --- a/Services/ConduitLLM.Gateway/Services/EphemeralKeyService.cs +++ b/Services/ConduitLLM.Gateway/Services/EphemeralKeyService.cs @@ -1,7 +1,8 @@ using System.Security.Cryptography; using System.Text; -using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Core.Services; using ConduitLLM.Gateway.Models; namespace ConduitLLM.Gateway.Services @@ -69,36 +70,58 @@ public interface IEphemeralKeyService Task GetKeyDataAsync(string key); } - public class EphemeralKeyService : IEphemeralKeyService + /// + /// Implementation of the ephemeral key service for Gateway API authentication + /// + public class EphemeralKeyService : EphemeralKeyServiceBase, IEphemeralKeyService { - private readonly IDistributedCache _cache; - private readonly ILogger _logger; - private const string KeyPrefix = "ephemeral:"; - private const int TTLSeconds = 900; // 15 minutes - longer for video generation which can take several minutes - + private const int DefaultTTLSeconds = 900; // 15 minutes - longer for video generation which can take several minutes + // Use a static key for encryption - in production this should come from configuration // This is just for data protection at rest in Redis // AES-256 requires exactly 32 bytes (256 bits) // This base64 string decodes to exactly 32 bytes: "ThisIsA32ByteKeyForAES256Encrypt" private static readonly byte[] EncryptionKey = Convert.FromBase64String("VGhpc0lzQTMyQnl0ZUtleUZvckFFUzI1NkVuY3J5cHQ="); + /// + protected override string KeyPrefix => CacheKeys.Ephemeral.Prefix; + + /// + protected override string TokenPrefix => CacheKeys.Ephemeral.TokenPrefix; + + /// + protected override int TTLSeconds => DefaultTTLSeconds; + + /// + /// Initializes a new instance of the class. + /// + /// The distributed cache + /// The logger public EphemeralKeyService( IDistributedCache cache, ILogger logger) + : base(cache, logger) { - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + protected override bool IsKeyConsumed(EphemeralKeyData keyData) => keyData.IsConsumed; + + /// + protected override DateTimeOffset GetKeyExpiration(EphemeralKeyData keyData) => keyData.ExpiresAt; + + /// + protected override void MarkKeyAsConsumed(EphemeralKeyData keyData) => keyData.IsConsumed = true; + + /// public async Task CreateEphemeralKeyAsync(int virtualKeyId, string virtualKey, EphemeralKeyMetadata? metadata = null) { - // Generate a cryptographically secure token var key = GenerateSecureToken(); var expiresAt = DateTimeOffset.UtcNow.AddSeconds(TTLSeconds); // Encrypt the virtual key for storage var encryptedVirtualKey = EncryptString(virtualKey); - + var keyData = new EphemeralKeyData { Key = key, @@ -110,19 +133,9 @@ public async Task CreateEphemeralKeyAsync(int virtualKeyId EncryptedVirtualKey = encryptedVirtualKey }; - // Store in Redis with TTL - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = JsonSerializer.Serialize(keyData); - - await _cache.SetStringAsync( - cacheKey, - serializedData, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(TTLSeconds) - }); + await StoreKeyDataAsync(key, keyData); - _logger.LogInformation("Created ephemeral key for virtual key {VirtualKeyId}, expires at {ExpiresAt}", + Logger.LogInformation("Created ephemeral key for virtual key {VirtualKeyId}, expires at {ExpiresAt}", virtualKeyId, expiresAt); return new EphemeralKeyResponse @@ -133,194 +146,54 @@ await _cache.SetStringAsync( }; } + /// public async Task ValidateAndConsumeKeyAsync(string key) { - if (string.IsNullOrEmpty(key)) - { - _logger.LogDebug("Ephemeral key validation failed: empty key"); - return null; - } - - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = await _cache.GetStringAsync(cacheKey); - - if (string.IsNullOrEmpty(serializedData)) - { - _logger.LogWarning("Ephemeral key not found: {Key}", SanitizeKeyForLogging(key)); - return null; - } - - var keyData = JsonSerializer.Deserialize(serializedData); + var keyData = await ValidateAndConsumeKeyInternalAsync(key); if (keyData == null) { - _logger.LogError("Failed to deserialize ephemeral key data for key: {Key}", SanitizeKeyForLogging(key)); - return null; - } - - // Check if already consumed - if (keyData.IsConsumed) - { - _logger.LogWarning("Ephemeral key already used: {Key}", SanitizeKeyForLogging(key)); - return null; - } - - // Check expiration - if (keyData.ExpiresAt < DateTimeOffset.UtcNow) - { - _logger.LogWarning("Ephemeral key expired: {Key}, expired at {ExpiresAt}", - SanitizeKeyForLogging(key), keyData.ExpiresAt); - // Clean up expired key - await _cache.RemoveAsync(cacheKey); return null; } - // Mark as consumed but keep in cache for cleanup - keyData.IsConsumed = true; - serializedData = JsonSerializer.Serialize(keyData); - - // Update with short TTL for cleanup tracking - await _cache.SetStringAsync( - cacheKey, - serializedData, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30) // Keep for 30s for cleanup - }); - - _logger.LogInformation("Consumed ephemeral key for virtual key {VirtualKeyId}", keyData.VirtualKeyId); - + Logger.LogInformation("Consumed ephemeral key for virtual key {VirtualKeyId}", keyData.VirtualKeyId); return keyData.VirtualKeyId; } + /// public async Task ConsumeKeyAsync(string key) { - // Similar to ValidateAndConsumeKeyAsync but doesn't delete - // Used for streaming where we need to maintain the connection - if (string.IsNullOrEmpty(key)) - { - return null; - } - - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = await _cache.GetStringAsync(cacheKey); - - if (string.IsNullOrEmpty(serializedData)) - { - _logger.LogWarning("Ephemeral key not found for consumption: {Key}", SanitizeKeyForLogging(key)); - return null; - } - - var keyData = JsonSerializer.Deserialize(serializedData); + var keyData = await ConsumeKeyInternalAsync(key); if (keyData == null) { return null; } - if (keyData.IsConsumed) - { - _logger.LogWarning("Attempted to consume already-used ephemeral key: {Key}", SanitizeKeyForLogging(key)); - return null; - } - - if (keyData.ExpiresAt < DateTimeOffset.UtcNow) - { - _logger.LogWarning("Attempted to consume expired ephemeral key: {Key}", SanitizeKeyForLogging(key)); - await _cache.RemoveAsync(cacheKey); - return null; - } - - // For streaming, immediately delete the key after successful validation - // The connection itself is now authenticated - await _cache.RemoveAsync(cacheKey); - - _logger.LogInformation("Consumed and deleted ephemeral key for streaming, virtual key {VirtualKeyId}", + Logger.LogInformation("Consumed and deleted ephemeral key for streaming, virtual key {VirtualKeyId}", keyData.VirtualKeyId); - return keyData.VirtualKeyId; } - public async Task DeleteKeyAsync(string key) - { - if (string.IsNullOrEmpty(key)) - { - return; - } - - var cacheKey = $"{KeyPrefix}{key}"; - await _cache.RemoveAsync(cacheKey); - - _logger.LogDebug("Deleted ephemeral key: {Key}", SanitizeKeyForLogging(key)); - } - - public async Task KeyExistsAsync(string key) - { - if (string.IsNullOrEmpty(key)) - { - return false; - } - - var cacheKey = $"{KeyPrefix}{key}"; - var data = await _cache.GetStringAsync(cacheKey); - return !string.IsNullOrEmpty(data); - } - - private static string GenerateSecureToken() - { - const int tokenLength = 32; // 256 bits - var randomBytes = new byte[tokenLength]; - - using (var rng = RandomNumberGenerator.Create()) - { - rng.GetBytes(randomBytes); - } - - // Convert to URL-safe base64 - var token = Convert.ToBase64String(randomBytes) - .Replace('+', '-') - .Replace('/', '_') - .TrimEnd('='); - - // Add prefix - return $"ek_{token}"; - } - - private static string SanitizeKeyForLogging(string key) - { - // Only show first 10 characters of the key for security - if (key.Length <= 10) - return key; - - return $"{key.Substring(0, 10)}..."; - } - + /// public async Task GetVirtualKeyAsync(string key) { if (string.IsNullOrEmpty(key)) { - _logger.LogDebug("GetVirtualKeyAsync: empty key"); - return null; - } - - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = await _cache.GetStringAsync(cacheKey); - - if (string.IsNullOrEmpty(serializedData)) - { - _logger.LogWarning("GetVirtualKeyAsync: Ephemeral key not found: {Key}", SanitizeKeyForLogging(key)); + Logger.LogDebug("GetVirtualKeyAsync: empty key"); return null; } - var keyData = JsonSerializer.Deserialize(serializedData); + var keyData = await GetKeyDataFromCacheAsync(key); if (keyData == null || string.IsNullOrEmpty(keyData.EncryptedVirtualKey)) { - _logger.LogError("GetVirtualKeyAsync: No encrypted virtual key found for ephemeral key: {Key}", SanitizeKeyForLogging(key)); + Logger.LogWarning("GetVirtualKeyAsync: Ephemeral key not found or no encrypted virtual key: {Key}", + SanitizeKeyForLogging(key)); return null; } // Check expiration if (keyData.ExpiresAt < DateTimeOffset.UtcNow) { - _logger.LogWarning("GetVirtualKeyAsync: Ephemeral key expired: {Key}", SanitizeKeyForLogging(key)); + Logger.LogWarning("GetVirtualKeyAsync: Ephemeral key expired: {Key}", SanitizeKeyForLogging(key)); return null; } @@ -331,17 +204,19 @@ private static string SanitizeKeyForLogging(string key) } catch (Exception ex) { - _logger.LogError(ex, "Failed to decrypt virtual key for ephemeral key: {Key}", SanitizeKeyForLogging(key)); + Logger.LogError(ex, "Failed to decrypt virtual key for ephemeral key: {Key}", SanitizeKeyForLogging(key)); return null; } } + /// public async Task GetVirtualKeyIdAsync(string key) { var keyData = await GetKeyDataAsync(key); return keyData?.VirtualKeyId; } + /// public async Task GetKeyDataAsync(string key) { if (string.IsNullOrEmpty(key)) @@ -349,15 +224,7 @@ private static string SanitizeKeyForLogging(string key) return null; } - var cacheKey = $"{KeyPrefix}{key}"; - var serializedData = await _cache.GetStringAsync(cacheKey); - - if (string.IsNullOrEmpty(serializedData)) - { - return null; - } - - return JsonSerializer.Deserialize(serializedData); + return await GetKeyDataFromCacheAsync(key); } private static string EncryptString(string plainText) @@ -399,4 +266,4 @@ private static string DecryptString(string cipherText) return Encoding.UTF8.GetString(plainBytes); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/GatewayOperationsMetricsService.cs b/Services/ConduitLLM.Gateway/Services/GatewayOperationsMetricsService.cs new file mode 100644 index 000000000..e81a7ff76 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/GatewayOperationsMetricsService.cs @@ -0,0 +1,218 @@ +using Prometheus; + +namespace ConduitLLM.Gateway.Services +{ + /// + /// Service for tracking Gateway API specific operational metrics. + /// Provides static recording methods for controllers to report operation-level metrics, + /// mirroring the pattern established by AdminOperationsMetricsService. + /// + public class GatewayOperationsMetricsService : BackgroundService + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly TimeSpan _collectionInterval = TimeSpan.FromMinutes(1); + + // LLM operation metrics + private static readonly Counter LlmOperations = Prometheus.Metrics + .CreateCounter("conduit_gateway_llm_operations_total", "Total LLM operations by type", + new CounterConfiguration + { + LabelNames = new[] { "operation", "model", "status" } // operation: chat_completion, embedding, image_generation, video_generation + }); + + private static readonly Histogram LlmOperationDuration = Prometheus.Metrics + .CreateHistogram("conduit_gateway_llm_operation_duration_seconds", "LLM operation duration", + new HistogramConfiguration + { + LabelNames = new[] { "operation", "model" }, + Buckets = Histogram.ExponentialBuckets(0.01, 2, 16) // 10ms to ~327s + }); + + // Batch operation metrics + private static readonly Counter BatchOperations = Prometheus.Metrics + .CreateCounter("conduit_gateway_batch_operations_total", "Total batch operations", + new CounterConfiguration + { + LabelNames = new[] { "operation", "status" } // operation: spend_update, virtualkey_update, webhook_send + }); + + private static readonly Histogram BatchSize = Prometheus.Metrics + .CreateHistogram("conduit_gateway_batch_size", "Number of items per batch operation", + new HistogramConfiguration + { + LabelNames = new[] { "operation" }, + Buckets = new[] { 1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000.0 } + }); + + private static readonly Histogram BatchDuration = Prometheus.Metrics + .CreateHistogram("conduit_gateway_batch_duration_seconds", "Batch operation duration", + new HistogramConfiguration + { + LabelNames = new[] { "operation" }, + Buckets = Histogram.ExponentialBuckets(0.01, 2, 14) // 10ms to ~82s + }); + + // Media operation metrics + private static readonly Counter MediaOperations = Prometheus.Metrics + .CreateCounter("conduit_gateway_media_operations_total", "Total media operations", + new CounterConfiguration + { + LabelNames = new[] { "operation", "media_type", "status" } // media_type: image, video; operation: generate, upload, download + }); + + private static readonly Histogram MediaGenerationDuration = Prometheus.Metrics + .CreateHistogram("conduit_gateway_media_generation_duration_seconds", "Media generation duration", + new HistogramConfiguration + { + LabelNames = new[] { "media_type", "model" }, + Buckets = Histogram.ExponentialBuckets(0.1, 2, 14) // 100ms to ~820s + }); + + // Function execution metrics + private static readonly Counter FunctionExecutions = Prometheus.Metrics + .CreateCounter("conduit_gateway_function_executions_total", "Total function executions", + new CounterConfiguration + { + LabelNames = new[] { "status" } // status: success, failure, timeout + }); + + private static readonly Histogram FunctionExecutionDuration = Prometheus.Metrics + .CreateHistogram("conduit_gateway_function_execution_duration_seconds", "Function execution duration", + new HistogramConfiguration + { + Buckets = Histogram.ExponentialBuckets(0.001, 2, 14) // 1ms to ~16s + }); + + // Streaming metrics + private static readonly Counter StreamingRequests = Prometheus.Metrics + .CreateCounter("conduit_gateway_streaming_requests_total", "Total streaming requests", + new CounterConfiguration + { + LabelNames = new[] { "model", "status" } + }); + + // Provider routing metrics + private static readonly Counter RoutingDecisions = Prometheus.Metrics + .CreateCounter("conduit_gateway_routing_decisions_total", "Total provider routing decisions", + new CounterConfiguration + { + LabelNames = new[] { "model", "provider", "reason" } // reason: primary, fallback, round_robin, least_loaded + }); + + public GatewayOperationsMetricsService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("GatewayOperationsMetricsService starting with collection interval {Interval}", _collectionInterval); + + // Brief delay to let other services initialize first + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CollectMetricsAsync(); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error collecting gateway operations metrics"); + } + + await Task.Delay(_collectionInterval, stoppingToken); + } + + _logger.LogInformation("GatewayOperationsMetricsService stopped"); + } + + private Task CollectMetricsAsync() + { + // Currently all metrics are recorded in real-time via static methods. + // This method is reserved for future periodic gauge collection + // (e.g., querying task queue depth, active streaming connections). + _logger.LogDebug("Gateway operations metrics collection cycle completed"); + return Task.CompletedTask; + } + + // Static methods to be called by Gateway controllers and services + + /// + /// Records an LLM operation (chat completion, embedding, image/video generation). + /// + public static void RecordLlmOperation(string operation, string model, string status, double? durationSeconds = null) + { + LlmOperations.WithLabels(operation, model, status).Inc(); + if (durationSeconds.HasValue) + { + LlmOperationDuration.WithLabels(operation, model).Observe(durationSeconds.Value); + } + } + + /// + /// Records a batch operation. + /// + public static void RecordBatchOperation(string operation, string status, int itemCount = 0, double? durationSeconds = null) + { + BatchOperations.WithLabels(operation, status).Inc(); + if (itemCount > 0) + { + BatchSize.WithLabels(operation).Observe(itemCount); + } + if (durationSeconds.HasValue) + { + BatchDuration.WithLabels(operation).Observe(durationSeconds.Value); + } + } + + /// + /// Records a media operation (generation, upload, download). + /// + public static void RecordMediaOperation(string operation, string mediaType, string status, double? durationSeconds = null, string? model = null) + { + MediaOperations.WithLabels(operation, mediaType, status).Inc(); + if (durationSeconds.HasValue && model != null && operation == "generate") + { + MediaGenerationDuration.WithLabels(mediaType, model).Observe(durationSeconds.Value); + } + } + + /// + /// Records a function execution. + /// + public static void RecordFunctionExecution(string status, double? durationSeconds = null) + { + FunctionExecutions.WithLabels(status).Inc(); + if (durationSeconds.HasValue) + { + FunctionExecutionDuration.Observe(durationSeconds.Value); + } + } + + /// + /// Records a streaming request. + /// + public static void RecordStreamingRequest(string model, string status) + { + StreamingRequests.WithLabels(model, status).Inc(); + } + + /// + /// Records a provider routing decision. + /// + public static void RecordRoutingDecision(string model, string provider, string reason) + { + RoutingDecisions.WithLabels(model, provider, reason).Inc(); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/HealthMonitoringBackgroundService.cs b/Services/ConduitLLM.Gateway/Services/HealthMonitoringBackgroundService.cs index 4d8c5f047..01bd892b1 100644 --- a/Services/ConduitLLM.Gateway/Services/HealthMonitoringBackgroundService.cs +++ b/Services/ConduitLLM.Gateway/Services/HealthMonitoringBackgroundService.cs @@ -33,7 +33,10 @@ public HealthMonitoringBackgroundService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Health monitoring background service started"); + _logger.LogInformation( + "Health monitoring background service started with {IntervalSeconds}s check interval, " + + "consecutive failure threshold: {FailureThreshold}", + _options.CheckIntervalSeconds, _options.ConsecutiveFailureThreshold); while (!stoppingToken.IsCancellationRequested) { @@ -54,6 +57,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task MonitorHealthAsync(CancellationToken cancellationToken) { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var healthReport = await _healthCheckService.CheckHealthAsync(cancellationToken); // Check overall system health @@ -71,6 +76,34 @@ private async Task MonitorHealthAsync(CancellationToken cancellationToken) var snapshot = await healthMonitoringService.GetSystemHealthSnapshotAsync(); await CheckResourceMetricsAsync(snapshot.Resources, scope.ServiceProvider); await CheckPerformanceMetricsAsync(snapshot.Performance, scope.ServiceProvider); + + stopwatch.Stop(); + + var unhealthyCount = healthReport.Entries + .Count(e => e.Value.Status == Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy); + var degradedCount = healthReport.Entries + .Count(e => e.Value.Status == Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Degraded); + + if (unhealthyCount > 0 || degradedCount > 0) + { + _logger.LogWarning( + "Health check cycle completed in {ElapsedMs}ms โ€” overall: {OverallStatus}, " + + "components: {TotalCount} total, {UnhealthyCount} unhealthy, {DegradedCount} degraded", + stopwatch.ElapsedMilliseconds, + healthReport.Status, + healthReport.Entries.Count, + unhealthyCount, + degradedCount); + } + else + { + _logger.LogDebug( + "Health check cycle completed in {ElapsedMs}ms โ€” overall: {OverallStatus}, " + + "{ComponentCount} components all healthy", + stopwatch.ElapsedMilliseconds, + healthReport.Status, + healthReport.Entries.Count); + } } private async Task CheckOverallHealthAsync(HealthReport healthReport) @@ -121,8 +154,15 @@ private async Task CheckComponentHealthAsync(string componentName, HealthReportE } // Check if status changed or consecutive failures exceed threshold - if (currentStatus != previousStatus || - (_consecutiveFailures[componentName] >= _options.ConsecutiveFailureThreshold && + if (currentStatus != previousStatus) + { + _logger.LogInformation( + "Health status change for {Component}: {PreviousStatus} โ†’ {CurrentStatus} (check duration: {DurationMs:F1}ms)", + componentName, previousStatus, currentStatus, entry.Duration.TotalMilliseconds); + } + + if (currentStatus != previousStatus || + (_consecutiveFailures[componentName] >= _options.ConsecutiveFailureThreshold && _consecutiveFailures[componentName] % _options.ConsecutiveFailureThreshold == 0)) { AlertSeverity severity; diff --git a/Services/ConduitLLM.Gateway/Services/IPerformanceMonitoringService.cs b/Services/ConduitLLM.Gateway/Services/IPerformanceMonitoringService.cs new file mode 100644 index 000000000..9b4a48c92 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/IPerformanceMonitoringService.cs @@ -0,0 +1,17 @@ +using ConduitLLM.Configuration.DTOs.HealthMonitoring; + +namespace ConduitLLM.Gateway.Services +{ + /// + /// Service for monitoring performance metrics and triggering alerts based on thresholds + /// + public interface IPerformanceMonitoringService + { + void RecordRequestMetric(string endpoint, double responseTimeMs, bool isSuccess); + void RecordDatabaseQueryMetric(string operation, double executionTimeMs); + void RecordCacheMetric(string operation, bool isHit); + void RecordConnectionPoolMetric(string poolName, int active, int idle, int waitQueue); + Task GetCurrentMetricsAsync(); + Task> GetEndpointMetricsAsync(); + } +} diff --git a/Services/ConduitLLM.Gateway/Services/ImageGenerationNotificationService.cs b/Services/ConduitLLM.Gateway/Services/ImageGenerationNotificationService.cs new file mode 100644 index 000000000..1323a6255 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/ImageGenerationNotificationService.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.SignalR; +using ConduitLLM.Gateway.Hubs; +using ConduitLLM.Gateway.Interfaces; +using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Services; + +namespace ConduitLLM.Gateway.Services +{ + /// + /// Implementation of image generation notification service using SignalR. + /// Inherits from SignalRNotificationServiceBase for common functionality. + /// + public class ImageGenerationNotificationService + : SignalRNotificationServiceBase, + IImageGenerationNotificationService + { + public ImageGenerationNotificationService( + IHubContext hubContext, + ILogger logger) + : base(hubContext, logger) + { + } + + public async Task NotifyImageGenerationStartedAsync(string taskId, string prompt, int numberOfImages, string size, string? style = null) + { + var groupName = SignalRConstants.Groups.ImageTask(taskId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.ImageGenerationStarted, new + { + taskId, + prompt, + numberOfImages, + size, + style, + startedAt = DateTime.UtcNow + }); + + Logger.LogInformation( + "[SignalR:ImageGenerationStarted] Sent notification - TaskId: {TaskId}, NumberOfImages: {NumberOfImages}, Size: {Size}, Group: {Group}", + taskId, numberOfImages, size, groupName); + } + + public async Task NotifyImageGenerationProgressAsync(string taskId, int progressPercentage, string status, int imagesCompleted, int totalImages, string? message = null) + { + var groupName = SignalRConstants.Groups.ImageTask(taskId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.ImageGenerationProgress, new + { + taskId, + progressPercentage, + status, + imagesCompleted, + totalImages, + message, + timestamp = DateTime.UtcNow + }); + + Logger.LogDebug("Sent ImageGenerationProgress notification for task {TaskId}: {Progress}% ({ImagesCompleted}/{TotalImages})", + taskId, progressPercentage, imagesCompleted, totalImages); + } + + public async Task NotifyImageGenerationCompletedAsync(string taskId, string[] imageUrls, TimeSpan duration, decimal cost) + { + var groupName = SignalRConstants.Groups.ImageTask(taskId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.ImageGenerationCompleted, new + { + taskId, + imageUrls, + durationSeconds = duration.TotalSeconds, + cost, + completedAt = DateTime.UtcNow + }); + + Logger.LogInformation("Sent ImageGenerationCompleted notification for task {TaskId} with {ImageCount} images to group {GroupName}", + taskId, imageUrls.Length, groupName); + } + + public async Task NotifyImageGenerationFailedAsync(string taskId, string error, bool isRetryable) + { + var groupName = SignalRConstants.Groups.ImageTask(taskId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.ImageGenerationFailed, new + { + taskId, + error, + isRetryable, + failedAt = DateTime.UtcNow + }); + + Logger.LogDebug("Sent ImageGenerationFailed notification for task {TaskId}", taskId); + } + + public async Task NotifyImageGenerationCancelledAsync(string taskId, string? reason) + { + var groupName = SignalRConstants.Groups.ImageTask(taskId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.ImageGenerationCancelled, new + { + taskId, + reason, + cancelledAt = DateTime.UtcNow + }); + + Logger.LogDebug("Sent ImageGenerationCancelled notification for task {TaskId}", taskId); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/MediaMaintenanceBackgroundService.cs b/Services/ConduitLLM.Gateway/Services/MediaMaintenanceBackgroundService.cs index 5301bad29..410b3d39b 100644 --- a/Services/ConduitLLM.Gateway/Services/MediaMaintenanceBackgroundService.cs +++ b/Services/ConduitLLM.Gateway/Services/MediaMaintenanceBackgroundService.cs @@ -47,7 +47,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (!_options.EnableAutoCleanup) { - _logger.LogWarning("Media auto cleanup is disabled. Media maintenance service will not run."); + _logger.LogInformation("Media auto cleanup is disabled. Media maintenance service will not run."); return; } diff --git a/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Analysis.cs b/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Analysis.cs index 645915672..3d45d8a2d 100644 --- a/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Analysis.cs +++ b/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Analysis.cs @@ -81,8 +81,12 @@ private async Task CheckAndSendAlerts(MetricsSnapshot snapshot, CancellationToke }); } - if (alerts.Count() > 0) + if (alerts.Any()) { + _logger.LogWarning("Metrics threshold alerts triggered: {AlertCount} alert(s) โ€” {AlertSummary}", + alerts.Count, + string.Join(", ", alerts.Select(a => $"{a.MetricName}={a.CurrentValue:F1} [{a.Severity}]"))); + await _hubContext.Clients.Group("metrics-subscribers") .SendAsync("MetricAlerts", alerts, cancellationToken); } diff --git a/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Collection.cs b/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Collection.cs index 478879844..e2aabeaf8 100644 --- a/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Collection.cs +++ b/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.Collection.cs @@ -139,7 +139,7 @@ private void CollectInfrastructureMetrics(MetricsSnapshot snapshot) /// /// Collect business-related metrics /// - private async void CollectBusinessMetrics(MetricsSnapshot snapshot) + private async Task CollectBusinessMetricsAsync(MetricsSnapshot snapshot) { try { @@ -182,11 +182,10 @@ private async void CollectBusinessMetrics(MetricsSnapshot snapshot) // Top virtual keys by spend var virtualKeyRepo = scope.ServiceProvider.GetRequiredService(); - var allKeys = await virtualKeyRepo.GetAllAsync(); + // Use optimized query that filters and limits at database level + var topKeys = await virtualKeyRepo.GetTopEnabledAsync(5); // Note: Spend tracking is now at the group level - snapshot.Business.TopVirtualKeys = allKeys - .Where(k => k.IsEnabled) - .Take(5) + snapshot.Business.TopVirtualKeys = topKeys .Select(k => new VirtualKeyStats { KeyId = k.Id.ToString(), diff --git a/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.cs b/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.cs index 8cf9a1b45..3a8d4bb31 100644 --- a/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.cs +++ b/Services/ConduitLLM.Gateway/Services/MetricsAggregationService.cs @@ -39,31 +39,46 @@ public MetricsAggregationService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Metrics aggregation service starting..."); + _logger.LogInformation("Metrics aggregation service starting with {IntervalSeconds}s collection interval", + _updateInterval.TotalSeconds); while (!stoppingToken.IsCancellationRequested) { try { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var snapshot = await CollectMetricsSnapshotAsync(); _lastSnapshot = snapshot; - + // Store historical data StoreHistoricalData(snapshot); - + // Broadcast to all subscribers await _hubContext.Clients.Group("metrics-subscribers") .SendAsync("MetricsSnapshot", snapshot, stoppingToken); - + // Send targeted updates await SendTargetedUpdates(snapshot, stoppingToken); - + // Check for alerts await CheckAndSendAlerts(snapshot, stoppingToken); + + stopwatch.Stop(); + _logger.LogDebug( + "Metrics aggregation cycle completed in {ElapsedMs}ms โ€” " + + "HTTP requests/s: {RequestsPerSec:F1}, error rate: {ErrorRate:F1}%, " + + "active requests: {ActiveRequests}, CPU: {Cpu:F1}%, memory: {MemoryMB:F0}MB", + stopwatch.ElapsedMilliseconds, + snapshot.Http.RequestsPerSecond, + snapshot.Http.ErrorRate, + snapshot.Http.ActiveRequests, + snapshot.System.CpuUsagePercent, + snapshot.System.MemoryUsageMB); } catch (Exception ex) { - _logger.LogError(ex, "Error in metrics aggregation"); + _logger.LogError(ex, "Error in metrics aggregation cycle"); } await Task.Delay(_updateInterval, stoppingToken); @@ -89,15 +104,16 @@ private async Task CollectMetricsSnapshotAsync() Timestamp = DateTime.UtcNow }; - var tasks = new[] - { - Task.Run(() => CollectHttpMetrics(snapshot)), - Task.Run(() => CollectInfrastructureMetrics(snapshot)), - Task.Run(() => CollectBusinessMetrics(snapshot)), - Task.Run(() => CollectSystemMetrics(snapshot)) - }; + // Kick off the only I/O-bound collector so it overlaps with the cheap synchronous + // ones below. The sync collectors are fast in-memory metric reads; wrapping them + // in Task.Run only adds scheduling overhead. + var businessTask = CollectBusinessMetricsAsync(snapshot); + + CollectHttpMetrics(snapshot); + CollectInfrastructureMetrics(snapshot); + CollectSystemMetrics(snapshot); - await Task.WhenAll(tasks); + await businessTask; return snapshot; } @@ -132,7 +148,7 @@ public async Task GetHistoricalMetricsAsync(Historica ] }; - if (filteredSeries.DataPoints.Count() > 0) + if (filteredSeries.DataPoints.Any()) { response.Series.Add(filteredSeries); } diff --git a/Services/ConduitLLM.Gateway/Services/ModelMetadataService.cs b/Services/ConduitLLM.Gateway/Services/ModelMetadataService.cs index 7e7e34260..be5ee191e 100644 --- a/Services/ConduitLLM.Gateway/Services/ModelMetadataService.cs +++ b/Services/ConduitLLM.Gateway/Services/ModelMetadataService.cs @@ -60,7 +60,7 @@ private async Task LoadMetadataIfNeededAsync() { lock (_cacheLock) { - if (DateTime.UtcNow - _lastCacheUpdate < _cacheExpiry && _metadataCache.Count() > 0) + if (DateTime.UtcNow - _lastCacheUpdate < _cacheExpiry && _metadataCache.Any()) { return; } diff --git a/Services/ConduitLLM.Gateway/Services/NoOpWebhookDeliveryTracker.cs b/Services/ConduitLLM.Gateway/Services/NoOpWebhookDeliveryTracker.cs index 47a827432..78d21985f 100644 --- a/Services/ConduitLLM.Gateway/Services/NoOpWebhookDeliveryTracker.cs +++ b/Services/ConduitLLM.Gateway/Services/NoOpWebhookDeliveryTracker.cs @@ -3,33 +3,57 @@ namespace ConduitLLM.Gateway.Services { /// - /// No-operation implementation of webhook delivery tracker - /// Used when Redis is not available + /// No-operation implementation of webhook delivery tracker. + /// Used when Redis is not available โ€” delivery deduplication is disabled. /// public class NoOpWebhookDeliveryTracker : IWebhookDeliveryTracker { + private readonly ILogger _logger; + private int _loggedFallbackWarning; + + public NoOpWebhookDeliveryTracker(ILogger logger) + { + _logger = logger; + } + public Task IsDeliveredAsync(string deliveryKey) { - // Always return false to allow delivery + LogFallbackWarningOnce(); + // Always return false to allow delivery โ€” no deduplication without Redis return Task.FromResult(false); } - + public Task MarkDeliveredAsync(string deliveryKey, string webhookUrl) { - // No-op + // No-op โ€” delivery tracking disabled without Redis return Task.CompletedTask; } - + public Task GetStatsAsync(string webhookUrl) { - // Return empty stats + // Return empty stats โ€” no tracking available return Task.FromResult(new WebhookDeliveryStats()); } - + public Task RecordFailureAsync(string deliveryKey, string webhookUrl, string error) { - // No-op + _logger.LogDebug( + "Webhook delivery failure not tracked (Redis unavailable): {DeliveryKey} to {WebhookUrl}", + deliveryKey, webhookUrl); return Task.CompletedTask; } + + /// + /// Logs a one-time warning that the no-op tracker is active (Redis unavailable). + /// + private void LogFallbackWarningOnce() + { + if (Interlocked.CompareExchange(ref _loggedFallbackWarning, 1, 0) == 0) + { + _logger.LogWarning( + "NoOpWebhookDeliveryTracker is active โ€” webhook delivery deduplication is disabled. " + + "Configure Redis to enable delivery tracking"); + } + } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/PerformanceMonitoringService.cs b/Services/ConduitLLM.Gateway/Services/PerformanceMonitoringService.cs deleted file mode 100644 index 4a381629d..000000000 --- a/Services/ConduitLLM.Gateway/Services/PerformanceMonitoringService.cs +++ /dev/null @@ -1,603 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using ConduitLLM.Configuration.DTOs.HealthMonitoring; -using ConduitLLM.Gateway.Interfaces; - -namespace ConduitLLM.Gateway.Services -{ - /// - /// Service for monitoring performance metrics and triggering alerts based on thresholds - /// - public interface IPerformanceMonitoringService - { - void RecordRequestMetric(string endpoint, double responseTimeMs, bool isSuccess); - void RecordDatabaseQueryMetric(string operation, double executionTimeMs); - void RecordCacheMetric(string operation, bool isHit); - void RecordConnectionPoolMetric(string poolName, int active, int idle, int waitQueue); - Task GetCurrentMetricsAsync(); - Task> GetEndpointMetricsAsync(); - } - - /// - /// Implementation of performance monitoring service - /// - public class PerformanceMonitoringService : IPerformanceMonitoringService, IHostedService, IDisposable - { - private readonly IAlertManagementService _alertManagementService; - private readonly IMemoryCache _cache; - private readonly ILogger _logger; - private readonly PerformanceMonitoringOptions _options; - - // Metrics storage - private readonly ConcurrentDictionary _endpointMetrics; - private readonly ConcurrentQueue _recentRequests; - private readonly ConcurrentQueue _recentDatabaseOps; - private readonly ConcurrentDictionary _cacheMetrics; - private readonly ConcurrentDictionary _connectionPoolMetrics; - - private Timer? _metricsAggregationTimer; - private Timer? _thresholdCheckTimer; - private readonly SemaphoreSlim _aggregationSemaphore; - - public PerformanceMonitoringService( - IAlertManagementService alertManagementService, - IMemoryCache cache, - ILogger logger, - IOptions options) - { - _alertManagementService = alertManagementService; - _cache = cache; - _logger = logger; - _options = options.Value; - - _endpointMetrics = new ConcurrentDictionary(); - _recentRequests = new ConcurrentQueue(); - _recentDatabaseOps = new ConcurrentQueue(); - _cacheMetrics = new ConcurrentDictionary(); - _connectionPoolMetrics = new ConcurrentDictionary(); - _aggregationSemaphore = new SemaphoreSlim(1, 1); - } - - /// - /// Record a request metric - /// - public void RecordRequestMetric(string endpoint, double responseTimeMs, bool isSuccess) - { - var metric = new RequestMetric - { - Endpoint = endpoint, - ResponseTimeMs = responseTimeMs, - IsSuccess = isSuccess, - Timestamp = DateTime.UtcNow - }; - - _recentRequests.Enqueue(metric); - - // Keep only recent metrics - while (_recentRequests.Count() > _options.MaxMetricsRetention) - { - _recentRequests.TryDequeue(out _); - } - - // Update endpoint-specific metrics - _endpointMetrics.AddOrUpdate(endpoint, - new EndpointMetrics - { - Endpoint = endpoint, - TotalRequests = 1, - SuccessfulRequests = isSuccess ? 1 : 0, - TotalResponseTime = responseTimeMs, - MaxResponseTime = responseTimeMs, - MinResponseTime = responseTimeMs, - LastUpdated = DateTime.UtcNow - }, - (key, existing) => - { - existing.TotalRequests++; - if (isSuccess) existing.SuccessfulRequests++; - existing.TotalResponseTime += responseTimeMs; - existing.MaxResponseTime = Math.Max(existing.MaxResponseTime, responseTimeMs); - existing.MinResponseTime = Math.Min(existing.MinResponseTime, responseTimeMs); - existing.LastUpdated = DateTime.UtcNow; - return existing; - }); - } - - /// - /// Record a database query metric - /// - public void RecordDatabaseQueryMetric(string operation, double executionTimeMs) - { - var metric = new DatabaseMetric - { - Operation = operation, - ExecutionTimeMs = executionTimeMs, - Timestamp = DateTime.UtcNow - }; - - _recentDatabaseOps.Enqueue(metric); - - // Keep only recent metrics - while (_recentDatabaseOps.Count() > _options.MaxMetricsRetention) - { - _recentDatabaseOps.TryDequeue(out _); - } - } - - /// - /// Record a cache metric - /// - public void RecordCacheMetric(string operation, bool isHit) - { - _cacheMetrics.AddOrUpdate(operation, - new CacheMetrics - { - Operation = operation, - TotalRequests = 1, - Hits = isHit ? 1 : 0, - LastUpdated = DateTime.UtcNow - }, - (key, existing) => - { - existing.TotalRequests++; - if (isHit) existing.Hits++; - existing.LastUpdated = DateTime.UtcNow; - return existing; - }); - } - - /// - /// Record connection pool metrics - /// - public void RecordConnectionPoolMetric(string poolName, int active, int idle, int waitQueue) - { - _connectionPoolMetrics.AddOrUpdate(poolName, - new ConnectionPoolMetrics - { - PoolName = poolName, - ActiveConnections = active, - IdleConnections = idle, - WaitQueueLength = waitQueue, - MaxActive = active, - LastUpdated = DateTime.UtcNow - }, - (key, existing) => - { - existing.ActiveConnections = active; - existing.IdleConnections = idle; - existing.WaitQueueLength = waitQueue; - existing.MaxActive = Math.Max(existing.MaxActive, active); - existing.LastUpdated = DateTime.UtcNow; - return existing; - }); - } - - /// - /// Get current performance metrics - /// - public async Task GetCurrentMetricsAsync() - { - await _aggregationSemaphore.WaitAsync(); - try - { - var now = DateTime.UtcNow; - var recentWindow = now.AddSeconds(-_options.MetricsWindowSeconds); - - // Calculate request metrics - var recentRequestsList = _recentRequests - .Where(r => r.Timestamp > recentWindow) - .ToList(); - - var requestCount = recentRequestsList.Count(); - var successCount = recentRequestsList.Count(r => r.IsSuccess); - var errorRate = requestCount > 0 ? ((double)(requestCount - successCount) / requestCount) * 100 : 0; - - var responseTimes = recentRequestsList - .Select(r => r.ResponseTimeMs) - .OrderBy(t => t) - .ToList(); - - var metrics = new PerformanceMetrics - { - RequestsPerSecond = requestCount / _options.MetricsWindowSeconds, - ErrorRatePercent = errorRate, - ActiveRequests = 0 // Would need to track this separately - }; - - if (responseTimes.Count() > 0) - { - metrics.AverageResponseTimeMs = responseTimes.Average(); - metrics.P95ResponseTimeMs = GetPercentile(responseTimes, 0.95); - metrics.P99ResponseTimeMs = GetPercentile(responseTimes, 0.99); - } - - return metrics; - } - finally - { - _aggregationSemaphore.Release(); - } - } - - /// - /// Get endpoint-specific metrics - /// - public Task> GetEndpointMetricsAsync() - { - return Task.FromResult(_endpointMetrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Performance monitoring service started"); - - // Start metrics aggregation timer - _metricsAggregationTimer = new Timer( - async _ => await AggregateMetricsAsync(), - null, - TimeSpan.FromSeconds(_options.AggregationIntervalSeconds), - TimeSpan.FromSeconds(_options.AggregationIntervalSeconds)); - - // Start threshold checking timer - _thresholdCheckTimer = new Timer( - async _ => await CheckThresholdsAsync(), - null, - TimeSpan.FromSeconds(_options.ThresholdCheckIntervalSeconds), - TimeSpan.FromSeconds(_options.ThresholdCheckIntervalSeconds)); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Performance monitoring service stopping"); - - _metricsAggregationTimer?.Change(Timeout.Infinite, 0); - _thresholdCheckTimer?.Change(Timeout.Infinite, 0); - - return Task.CompletedTask; - } - - private async Task AggregateMetricsAsync() - { - try - { - var metrics = await GetCurrentMetricsAsync(); - - // Store aggregated metrics in cache for historical tracking - var key = $"perf_metrics_{DateTime.UtcNow:yyyyMMddHHmmss}"; - _cache.Set(key, metrics, TimeSpan.FromHours(24)); - - _logger.LogDebug("Aggregated performance metrics: {RequestsPerSecond} req/s, {ErrorRate}% errors, {AvgResponse}ms avg response", - metrics.RequestsPerSecond, metrics.ErrorRatePercent, metrics.AverageResponseTimeMs); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error aggregating performance metrics"); - } - } - - private async Task CheckThresholdsAsync() - { - try - { - var metrics = await GetCurrentMetricsAsync(); - - // Check response time thresholds - if (metrics.P99ResponseTimeMs > _options.ResponseTimeP99CriticalMs) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Critical, - "Critical Response Time", - $"P99 response time is {metrics.P99ResponseTimeMs:F0}ms (threshold: {_options.ResponseTimeP99CriticalMs}ms)", - metrics); - } - else if (metrics.P95ResponseTimeMs > _options.ResponseTimeP95WarningMs) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Warning, - "High Response Time", - $"P95 response time is {metrics.P95ResponseTimeMs:F0}ms (threshold: {_options.ResponseTimeP95WarningMs}ms)", - metrics); - } - - // Check error rate thresholds - if (metrics.ErrorRatePercent > _options.ErrorRateCriticalPercent) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Critical, - "Critical Error Rate", - $"Error rate is {metrics.ErrorRatePercent:F1}% (threshold: {_options.ErrorRateCriticalPercent}%)", - metrics); - } - else if (metrics.ErrorRatePercent > _options.ErrorRateWarningPercent) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Warning, - "High Error Rate", - $"Error rate is {metrics.ErrorRatePercent:F1}% (threshold: {_options.ErrorRateWarningPercent}%)", - metrics); - } - - // Check request rate thresholds - if (metrics.RequestsPerSecond > _options.RequestRateHighThreshold) - { - await TriggerPerformanceAlertAsync( - AlertSeverity.Warning, - "High Request Rate", - $"Request rate is {metrics.RequestsPerSecond:F1} req/s (threshold: {_options.RequestRateHighThreshold} req/s)", - metrics); - } - - // Check database query performance - await CheckDatabasePerformanceAsync(); - - // Check cache performance - await CheckCachePerformanceAsync(); - - // Check connection pools - await CheckConnectionPoolsAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking performance thresholds"); - } - } - - private async Task CheckDatabasePerformanceAsync() - { - var recentWindow = DateTime.UtcNow.AddSeconds(-_options.MetricsWindowSeconds); - var recentQueries = _recentDatabaseOps - .Where(q => q.Timestamp > recentWindow) - .ToList(); - - if (recentQueries.Count() == 0) return; - - var slowQueries = recentQueries - .Where(q => q.ExecutionTimeMs > _options.DatabaseSlowQueryThresholdMs) - .ToList(); - - if (slowQueries.Count() > _options.DatabaseSlowQueryCountThreshold) - { - var avgSlowQueryTime = slowQueries.Average(q => q.ExecutionTimeMs); - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.PerformanceDegradation, - Component = "Database", - Title = "High Number of Slow Queries", - Message = $"Detected {slowQueries.Count()} slow queries in the last {_options.MetricsWindowSeconds} seconds. Average execution time: {avgSlowQueryTime:F0}ms", - Context = new Dictionary - { - ["slowQueryCount"] = slowQueries.Count(), - ["averageExecutionTime"] = avgSlowQueryTime, - ["threshold"] = _options.DatabaseSlowQueryThresholdMs, - ["operations"] = slowQueries.GroupBy(q => q.Operation) - .Select(g => new { Operation = g.Key, Count = g.Count() }) - .OrderByDescending(x => x.Count) - .Take(5) - .ToList() - }, - SuggestedActions = new List - { - "Review slow query log", - "Check for missing database indexes", - "Analyze query execution plans", - "Consider query optimization" - } - }); - } - } - - private async Task CheckCachePerformanceAsync() - { - foreach (var cacheMetric in _cacheMetrics.Values) - { - var hitRate = cacheMetric.TotalRequests > 0 - ? (double)cacheMetric.Hits / cacheMetric.TotalRequests * 100 - : 100; - - if (hitRate < _options.CacheHitRateLowThreshold) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.PerformanceDegradation, - Component = "Cache", - Title = $"Low Cache Hit Rate for {cacheMetric.Operation}", - Message = $"Cache hit rate is {hitRate:F1}% (threshold: {_options.CacheHitRateLowThreshold}%)", - Context = new Dictionary - { - ["operation"] = cacheMetric.Operation, - ["hitRate"] = hitRate, - ["totalRequests"] = cacheMetric.TotalRequests, - ["hits"] = cacheMetric.Hits - }, - SuggestedActions = new List - { - "Review cache eviction policies", - "Increase cache size if needed", - "Analyze cache key patterns", - "Check for cache invalidation issues" - } - }); - } - } - } - - private async Task CheckConnectionPoolsAsync() - { - foreach (var poolMetric in _connectionPoolMetrics.Values) - { - var totalConnections = poolMetric.ActiveConnections + poolMetric.IdleConnections; - var utilizationPercent = totalConnections > 0 - ? (double)poolMetric.ActiveConnections / totalConnections * 100 - : 0; - - if (utilizationPercent > _options.ConnectionPoolHighUtilizationThreshold) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Warning, - Type = AlertType.ResourceExhaustion, - Component = $"{poolMetric.PoolName} Connection Pool", - Title = $"High Connection Pool Utilization", - Message = $"Connection pool utilization is {utilizationPercent:F1}% with {poolMetric.WaitQueueLength} requests waiting", - Context = new Dictionary - { - ["poolName"] = poolMetric.PoolName, - ["activeConnections"] = poolMetric.ActiveConnections, - ["idleConnections"] = poolMetric.IdleConnections, - ["waitQueueLength"] = poolMetric.WaitQueueLength, - ["utilizationPercent"] = utilizationPercent - }, - SuggestedActions = new List - { - "Increase connection pool size", - "Review connection timeout settings", - "Check for connection leaks", - "Optimize query performance" - } - }); - } - - if (poolMetric.WaitQueueLength > _options.ConnectionPoolQueueWarningThreshold) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = AlertSeverity.Error, - Type = AlertType.ResourceExhaustion, - Component = $"{poolMetric.PoolName} Connection Pool", - Title = "Connection Pool Queue Buildup", - Message = $"Connection pool has {poolMetric.WaitQueueLength} requests waiting in queue", - Context = new Dictionary - { - ["poolName"] = poolMetric.PoolName, - ["waitQueueLength"] = poolMetric.WaitQueueLength, - ["activeConnections"] = poolMetric.ActiveConnections - } - }); - } - } - } - - private async Task TriggerPerformanceAlertAsync( - AlertSeverity severity, - string title, - string message, - PerformanceMetrics metrics) - { - await _alertManagementService.TriggerAlertAsync(new HealthAlert - { - Severity = severity, - Type = AlertType.PerformanceDegradation, - Component = "API Performance", - Title = title, - Message = message, - Context = new Dictionary - { - ["requestsPerSecond"] = metrics.RequestsPerSecond, - ["errorRatePercent"] = metrics.ErrorRatePercent, - ["averageResponseTime"] = metrics.AverageResponseTimeMs, - ["p95ResponseTime"] = metrics.P95ResponseTimeMs, - ["p99ResponseTime"] = metrics.P99ResponseTimeMs - } - }); - } - - private double GetPercentile(List sortedValues, double percentile) - { - if (sortedValues.Count() == 0) return 0; - - var index = (int)Math.Ceiling(percentile * sortedValues.Count()) - 1; - return sortedValues[Math.Max(0, Math.Min(index, sortedValues.Count() - 1))]; - } - - public void Dispose() - { - _metricsAggregationTimer?.Dispose(); - _thresholdCheckTimer?.Dispose(); - _aggregationSemaphore?.Dispose(); - } - } - - // Supporting classes - public class RequestMetric - { - public string Endpoint { get; set; } = string.Empty; - public double ResponseTimeMs { get; set; } - public bool IsSuccess { get; set; } - public DateTime Timestamp { get; set; } - } - - public class DatabaseMetric - { - public string Operation { get; set; } = string.Empty; - public double ExecutionTimeMs { get; set; } - public DateTime Timestamp { get; set; } - } - - public class CacheMetrics - { - public string Operation { get; set; } = string.Empty; - public int TotalRequests { get; set; } - public int Hits { get; set; } - public DateTime LastUpdated { get; set; } - } - - public class ConnectionPoolMetrics - { - public string PoolName { get; set; } = string.Empty; - public int ActiveConnections { get; set; } - public int IdleConnections { get; set; } - public int WaitQueueLength { get; set; } - public int MaxActive { get; set; } - public DateTime LastUpdated { get; set; } - } - - public class EndpointMetrics - { - public string Endpoint { get; set; } = string.Empty; - public int TotalRequests { get; set; } - public int SuccessfulRequests { get; set; } - public double TotalResponseTime { get; set; } - public double MaxResponseTime { get; set; } - public double MinResponseTime { get; set; } - public DateTime LastUpdated { get; set; } - - public double AverageResponseTime => TotalRequests > 0 ? TotalResponseTime / TotalRequests : 0; - public double SuccessRate => TotalRequests > 0 ? (double)SuccessfulRequests / TotalRequests * 100 : 100; - } - - public class PerformanceMonitoringOptions - { - // Metrics collection - public int MaxMetricsRetention { get; set; } = 10000; - public int MetricsWindowSeconds { get; set; } = 60; - public int AggregationIntervalSeconds { get; set; } = 30; - public int ThresholdCheckIntervalSeconds { get; set; } = 30; - - // Response time thresholds - public double ResponseTimeP95WarningMs { get; set; } = 1000; - public double ResponseTimeP99CriticalMs { get; set; } = 5000; - - // Error rate thresholds - public double ErrorRateWarningPercent { get; set; } = 1; - public double ErrorRateCriticalPercent { get; set; } = 5; - - // Request rate thresholds - public double RequestRateHighThreshold { get; set; } = 1000; - - // Database thresholds - public double DatabaseSlowQueryThresholdMs { get; set; } = 1000; - public int DatabaseSlowQueryCountThreshold { get; set; } = 10; - - // Cache thresholds - public double CacheHitRateLowThreshold { get; set; } = 80; - - // Connection pool thresholds - public double ConnectionPoolHighUtilizationThreshold { get; set; } = 80; - public int ConnectionPoolQueueWarningThreshold { get; set; } = 10; - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/RedisBatchOperations.cs b/Services/ConduitLLM.Gateway/Services/RedisBatchOperations.cs index b95b8d189..f381db362 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisBatchOperations.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisBatchOperations.cs @@ -164,9 +164,9 @@ public async Task BatchDeleteAsync(string[] keys) stopwatch.Stop(); - if (failedKeys.Count() > 0) + if (failedKeys.Any()) { - _logger.LogWarning("Failed to delete {Count} keys during batch delete", failedKeys.Count()); + _logger.LogWarning("Failed to delete {Count} keys during batch delete", failedKeys.Count); } return new BatchDeleteResult @@ -197,7 +197,7 @@ public async Task BatchDeleteAsync(string[] keys) public async Task BatchSetAsync(Dictionary keyValuePairs, TimeSpan? expiry = null) { - if (keyValuePairs == null || keyValuePairs.Count() == 0) + if (keyValuePairs == null || !keyValuePairs.Any()) { return new BatchSetResult { @@ -245,9 +245,9 @@ public async Task BatchSetAsync(Dictionary keyValu stopwatch.Stop(); - if (failedKeys.Count() > 0) + if (failedKeys.Any()) { - _logger.LogWarning("Failed to set {Count} keys during batch set", failedKeys.Count()); + _logger.LogWarning("Failed to set {Count} keys during batch set", failedKeys.Count); } return new BatchSetResult @@ -278,7 +278,7 @@ public async Task BatchSetAsync(Dictionary keyValu public async Task BatchPublishAsync(Dictionary channelMessages) { - if (channelMessages == null || channelMessages.Count() == 0) + if (channelMessages == null || !channelMessages.Any()) { return new BatchPublishResult { diff --git a/Services/ConduitLLM.Gateway/Services/RedisGlobalSettingCache.cs b/Services/ConduitLLM.Gateway/Services/RedisGlobalSettingCache.cs index 451d0c447..a14138546 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisGlobalSettingCache.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisGlobalSettingCache.cs @@ -1,153 +1,101 @@ using System.Text.Json; using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; namespace ConduitLLM.Gateway.Services { /// /// Redis-based Global Setting cache with event-driven invalidation /// - public class RedisGlobalSettingCache : IGlobalSettingCache + public class RedisGlobalSettingCache : RedisCacheServiceBase, IGlobalSettingCache { - private readonly IDatabase _database; - private readonly ILogger _logger; - private readonly TimeSpan _defaultExpiry = TimeSpan.FromHours(2); private readonly TimeSpan _authKeyExpiry = TimeSpan.FromMinutes(15); // Shorter expiry for auth keys - private const string KeyPrefix = "globalsetting:"; - private const string AuthKeyCache = "globalsetting:authkey"; - - // Statistics tracking keys - private const string STATS_HIT_KEY = "conduit:cache:globalsetting:stats:hits"; - private const string STATS_MISS_KEY = "conduit:cache:globalsetting:stats:misses"; - private const string STATS_INVALIDATION_KEY = "conduit:cache:globalsetting:stats:invalidations"; - private const string STATS_RESET_TIME_KEY = "conduit:cache:globalsetting:stats:reset_time"; - private const string STATS_AUTH_HIT_KEY = "conduit:cache:globalsetting:stats:auth_hits"; - private const string STATS_AUTH_MISS_KEY = "conduit:cache:globalsetting:stats:auth_misses"; - - private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; + + private static readonly string ServiceName = CacheKeys.Stats.GlobalSettingService; public RedisGlobalSettingCache( IConnectionMultiplexer redis, ILogger logger) + : base(redis, logger, TimeSpan.FromHours(2)) { - _database = redis.GetDatabase(); - _logger = logger; - - // Initialize stats reset time if not exists - _database.StringSetAsync(STATS_RESET_TIME_KEY, DateTime.UtcNow.ToString("O"), when: When.NotExists).GetAwaiter().GetResult(); + InitializeStatsResetTime(ServiceName); } /// /// Get Global Setting from cache with database fallback /// public async Task GetSettingAsync( - string settingKey, + string settingKey, Func> databaseFallback) { - var cacheKey = KeyPrefix + settingKey.ToLowerInvariant(); - - try - { - var cachedValue = await _database.StringGetAsync(cacheKey); - - if (cachedValue.HasValue) - { - var jsonString = (string?)cachedValue; - if (jsonString is not null) - { - var setting = JsonSerializer.Deserialize(jsonString, _jsonOptions); - - if (setting != null) - { - _logger.LogDebug("Global setting cache hit: {SettingKey}", settingKey); - await _database.StringIncrementAsync(STATS_HIT_KEY); - return setting; - } - } - } - - // Cache miss - fallback to database - _logger.LogDebug("Global setting cache miss, querying database: {SettingKey}", settingKey); - await _database.StringIncrementAsync(STATS_MISS_KEY); - - var dbSetting = await databaseFallback(settingKey); - - if (dbSetting != null) - { - // Cache the setting - await SetSettingAsync(dbSetting); - return dbSetting; - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error accessing Global Setting cache, falling back to database: {SettingKey}", settingKey); - await _database.StringIncrementAsync(STATS_MISS_KEY); - return await databaseFallback(settingKey); - } + var cacheKey = CacheKeys.GlobalSetting.Prefix + settingKey.ToLowerInvariant(); + + return await GetOrFallbackAsync( + cacheKey, + ServiceName, + () => databaseFallback(settingKey), + expiry: GetExpiryForKey(settingKey), + debugLabel: $"Global setting: {settingKey}"); } /// /// Get multiple Global Settings from cache with database fallback /// public async Task> GetSettingsAsync( - string[] settingKeys, + string[] settingKeys, Func>> databaseFallback) { var result = new Dictionary(); var missingKeys = new List(); - + try { // Try to get all settings from cache foreach (var key in settingKeys) { - var cacheKey = KeyPrefix + key.ToLowerInvariant(); - var cachedValue = await _database.StringGetAsync(cacheKey); - + var cacheKey = CacheKeys.GlobalSetting.Prefix + key.ToLowerInvariant(); + var cachedValue = await Database.StringGetAsync(cacheKey); + if (cachedValue.HasValue) { var jsonString = (string?)cachedValue; if (jsonString is not null) { - var setting = JsonSerializer.Deserialize(jsonString, _jsonOptions); + var setting = JsonSerializer.Deserialize(jsonString, JsonOptions); if (setting != null) { result[key] = setting; - await _database.StringIncrementAsync(STATS_HIT_KEY); + await TrackHitAsync(ServiceName); continue; } } } - + missingKeys.Add(key); - await _database.StringIncrementAsync(STATS_MISS_KEY); + await TrackMissAsync(ServiceName); } - + // Fetch missing settings from database - if (missingKeys.Count() > 0) + if (missingKeys.Any()) { - _logger.LogDebug("Global settings cache miss for {Count} keys, querying database", missingKeys.Count); + Logger.LogDebug("Global settings cache miss for {Count} keys, querying database", missingKeys.Count); var dbSettings = await databaseFallback(missingKeys.ToArray()); - + foreach (var setting in dbSettings) { result[setting.Key] = setting; await SetSettingAsync(setting); } } - + return result; } catch (Exception ex) { - _logger.LogError(ex, "Error accessing Global Settings cache, falling back to database"); + Logger.LogError(ex, "Error accessing Global Settings cache, falling back to database"); var dbSettings = await databaseFallback(settingKeys); return dbSettings.ToDictionary(s => s.Key, s => s); } @@ -160,34 +108,33 @@ public async Task> GetSettingsAsync( { try { - var cachedValue = await _database.StringGetAsync(AuthKeyCache); - + var cachedValue = await Database.StringGetAsync(CacheKeys.GlobalSetting.AuthKey); + if (cachedValue.HasValue) { - _logger.LogDebug("Authentication key cache hit"); - await _database.StringIncrementAsync(STATS_AUTH_HIT_KEY); + Logger.LogDebug("Authentication key cache hit"); + await Database.StringIncrementAsync(CacheKeys.Stats.AuthHits()); return (string?)cachedValue; } - + // Cache miss - fallback to database - _logger.LogDebug("Authentication key cache miss, querying database"); - await _database.StringIncrementAsync(STATS_AUTH_MISS_KEY); - + Logger.LogDebug("Authentication key cache miss, querying database"); + await Database.StringIncrementAsync(CacheKeys.Stats.AuthMisses()); + var authKey = await databaseFallback(); - + if (!string.IsNullOrEmpty(authKey)) { - // Cache with shorter expiry for auth keys - await _database.StringSetAsync(AuthKeyCache, authKey, _authKeyExpiry); + await Database.StringSetAsync(CacheKeys.GlobalSetting.AuthKey, authKey, _authKeyExpiry); return authKey; } - + return null; } catch (Exception ex) { - _logger.LogError(ex, "Error accessing authentication key cache, falling back to database"); - await _database.StringIncrementAsync(STATS_AUTH_MISS_KEY); + Logger.LogError(ex, "Error accessing authentication key cache, falling back to database"); + await Database.StringIncrementAsync(CacheKeys.Stats.AuthMisses()); return await databaseFallback(); } } @@ -199,21 +146,21 @@ public async Task InvalidateSettingAsync(string settingKey) { try { - var cacheKey = KeyPrefix + settingKey.ToLowerInvariant(); - await _database.KeyDeleteAsync(cacheKey); - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - + var cacheKey = CacheKeys.GlobalSetting.Prefix + settingKey.ToLowerInvariant(); + await Database.KeyDeleteAsync(cacheKey); + await TrackInvalidationAsync(ServiceName); + // If it's the auth key, invalidate the specialized cache too if (settingKey.Equals("AuthenticationKey", StringComparison.OrdinalIgnoreCase)) { - await _database.KeyDeleteAsync(AuthKeyCache); + await Database.KeyDeleteAsync(CacheKeys.GlobalSetting.AuthKey); } - - _logger.LogInformation("Global setting cache invalidated: {SettingKey}", settingKey); + + Logger.LogDebug("Global setting cache invalidated: {SettingKey}", settingKey); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating Global Setting cache: {SettingKey}", settingKey); + Logger.LogError(ex, "Error invalidating Global Setting cache: {SettingKey}", settingKey); } } @@ -224,21 +171,21 @@ public async Task InvalidateSettingsAsync(string[] settingKeys) { try { - var cacheKeys = settingKeys.Select(k => (RedisKey)(KeyPrefix + k.ToLowerInvariant())).ToArray(); - await _database.KeyDeleteAsync(cacheKeys); - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY, settingKeys.Length); - + var cacheKeys = settingKeys.Select(k => (RedisKey)(CacheKeys.GlobalSetting.Prefix + k.ToLowerInvariant())).ToArray(); + await Database.KeyDeleteAsync(cacheKeys); + await TrackInvalidationAsync(ServiceName, settingKeys.Length); + // Check if auth key is in the list if (settingKeys.Any(k => k.Equals("AuthenticationKey", StringComparison.OrdinalIgnoreCase))) { - await _database.KeyDeleteAsync(AuthKeyCache); + await Database.KeyDeleteAsync(CacheKeys.GlobalSetting.AuthKey); } - - _logger.LogInformation("Global settings cache invalidated: {Count} keys", settingKeys.Length); + + Logger.LogDebug("Global settings cache invalidated: {Count} keys", settingKeys.Length); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating multiple Global Settings cache"); + Logger.LogError(ex, "Error invalidating multiple Global Settings cache"); } } @@ -249,22 +196,14 @@ public async Task InvalidateAuthenticationSettingsAsync() { try { - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var authKeys = server.Keys(pattern: KeyPrefix + "auth*"); - - foreach (var key in authKeys) - { - await _database.KeyDeleteAsync(key); - } - - // Also invalidate the specialized auth key cache - await _database.KeyDeleteAsync(AuthKeyCache); - - _logger.LogWarning("All authentication-related settings cache entries cleared"); + await ClearAllByPatternAsync(CacheKeys.GlobalSetting.Prefix + "auth*"); + await Database.KeyDeleteAsync(CacheKeys.GlobalSetting.AuthKey); + + Logger.LogWarning("All authentication-related settings cache entries cleared"); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating authentication settings cache"); + Logger.LogError(ex, "Error invalidating authentication settings cache"); } } @@ -275,22 +214,14 @@ public async Task ClearAllSettingsAsync() { try { - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - - foreach (var key in keys) - { - await _database.KeyDeleteAsync(key); - } - - // Also clear the auth key cache - await _database.KeyDeleteAsync(AuthKeyCache); - - _logger.LogWarning("All global setting cache entries cleared"); + await ClearAllByPatternAsync(CacheKeys.GlobalSetting.Prefix + "*"); + await Database.KeyDeleteAsync(CacheKeys.GlobalSetting.AuthKey); + + Logger.LogWarning("All global setting cache entries cleared"); } catch (Exception ex) { - _logger.LogError(ex, "Error clearing all global setting cache entries"); + Logger.LogError(ex, "Error clearing all global setting cache entries"); } } @@ -301,53 +232,40 @@ public async Task GetStatsAsync() { try { - var hits = await _database.StringGetAsync(STATS_HIT_KEY); - var misses = await _database.StringGetAsync(STATS_MISS_KEY); - var invalidations = await _database.StringGetAsync(STATS_INVALIDATION_KEY); - var authHits = await _database.StringGetAsync(STATS_AUTH_HIT_KEY); - var authMisses = await _database.StringGetAsync(STATS_AUTH_MISS_KEY); - var resetTime = await _database.StringGetAsync(STATS_RESET_TIME_KEY); - - // Count entries - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - var entryCount = 0L; - foreach (var _ in keys) - { - entryCount++; - } - + var (hits, misses, invalidations, resetTime) = await GetBaseStatsAsync(ServiceName); + var authHits = await Database.StringGetAsync(CacheKeys.Stats.AuthHits()); + var authMisses = await Database.StringGetAsync(CacheKeys.Stats.AuthMisses()); + return new GlobalSettingCacheStats { - HitCount = hits.HasValue ? (long)hits : 0, - MissCount = misses.HasValue ? (long)misses : 0, - InvalidationCount = invalidations.HasValue ? (long)invalidations : 0, + HitCount = hits, + MissCount = misses, + InvalidationCount = invalidations, AuthKeyHits = authHits.HasValue ? (long)authHits : 0, AuthKeyMisses = authMisses.HasValue ? (long)authMisses : 0, - LastResetTime = resetTime.HasValue && DateTime.TryParse(resetTime, out var time) ? time : DateTime.UtcNow, - EntryCount = entryCount + LastResetTime = resetTime, + EntryCount = CountEntries(CacheKeys.GlobalSetting.Prefix + "*") }; } catch (Exception ex) { - _logger.LogError(ex, "Error getting global setting cache statistics"); + Logger.LogError(ex, "Error getting global setting cache statistics"); return new GlobalSettingCacheStats { LastResetTime = DateTime.UtcNow }; } } + private TimeSpan GetExpiryForKey(string settingKey) + { + return settingKey.StartsWith("Auth", StringComparison.OrdinalIgnoreCase) + ? _authKeyExpiry + : DefaultExpiry; + } + private async Task SetSettingAsync(GlobalSetting setting) { - var cacheKey = KeyPrefix + setting.Key.ToLowerInvariant(); - var serialized = JsonSerializer.Serialize(setting, _jsonOptions); - - // Use shorter expiry for auth-related settings - var expiry = setting.Key.StartsWith("Auth", StringComparison.OrdinalIgnoreCase) - ? _authKeyExpiry - : _defaultExpiry; - - await _database.StringSetAsync(cacheKey, serialized, expiry); - - _logger.LogDebug("Global setting cached: {SettingKey}", setting.Key); + var cacheKey = CacheKeys.GlobalSetting.Prefix + setting.Key.ToLowerInvariant(); + await SetCacheEntryAsync(cacheKey, setting, GetExpiryForKey(setting.Key)); + Logger.LogDebug("Global setting cached: {SettingKey}", setting.Key); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/RedisIpFilterCache.cs b/Services/ConduitLLM.Gateway/Services/RedisIpFilterCache.cs index bb566ee9c..04b8f2471 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisIpFilterCache.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisIpFilterCache.cs @@ -1,43 +1,25 @@ using System.Text.Json; using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; namespace ConduitLLM.Gateway.Services { /// /// Redis-based IP Filter cache with event-driven invalidation /// - public class RedisIpFilterCache : IIpFilterCache + public class RedisIpFilterCache : RedisCacheServiceBase, IIpFilterCache { - private readonly IDatabase _database; - private readonly ILogger _logger; - private readonly TimeSpan _defaultExpiry = TimeSpan.FromHours(1); // IP filters need quick updates - private const string GlobalFiltersKey = "ipfilter:global"; - private const string VirtualKeyFilterPrefix = "ipfilter:vkey:"; - private const string IpCheckPrefix = "ipfilter:check:"; - - // Statistics tracking keys - private const string STATS_HIT_KEY = "conduit:cache:ipfilter:stats:hits"; - private const string STATS_MISS_KEY = "conduit:cache:ipfilter:stats:misses"; - private const string STATS_INVALIDATION_KEY = "conduit:cache:ipfilter:stats:invalidations"; - private const string STATS_RESET_TIME_KEY = "conduit:cache:ipfilter:stats:reset_time"; - private const string STATS_IP_CHECK_KEY = "conduit:cache:ipfilter:stats:ip_checks"; - - private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; + private static readonly string ServiceName = CacheKeys.Stats.IpFilterService; public RedisIpFilterCache( IConnectionMultiplexer redis, ILogger logger) + : base(redis, logger, TimeSpan.FromHours(1)) { - _database = redis.GetDatabase(); - _logger = logger; - - // Initialize stats reset time if not exists - _database.StringSetAsync(STATS_RESET_TIME_KEY, DateTime.UtcNow.ToString("O"), when: When.NotExists).GetAwaiter().GetResult(); + InitializeStatsResetTime(ServiceName); } /// @@ -45,143 +27,73 @@ public RedisIpFilterCache( /// public async Task> GetGlobalFiltersAsync(Func>> databaseFallback) { - try - { - var cachedValue = await _database.StringGetAsync(GlobalFiltersKey); - - if (cachedValue.HasValue) - { - var jsonString = (string?)cachedValue; - if (jsonString is not null) - { - var filters = JsonSerializer.Deserialize>(jsonString, _jsonOptions); - - if (filters != null) - { - _logger.LogDebug("Global IP filters cache hit ({Count} filters)", filters.Count); - await _database.StringIncrementAsync(STATS_HIT_KEY); - return filters; - } - } - } - - // Cache miss - fallback to database - _logger.LogDebug("Global IP filters cache miss, querying database"); - await _database.StringIncrementAsync(STATS_MISS_KEY); - - var dbFilters = await databaseFallback(); - - if (dbFilters != null) - { - // Cache the filters - await SetGlobalFiltersAsync(dbFilters); - return dbFilters; - } - - return new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error accessing global IP filters cache, falling back to database"); - await _database.StringIncrementAsync(STATS_MISS_KEY); - return await databaseFallback() ?? new List(); - } + var result = await GetOrFallbackAsync>( + CacheKeys.IpFilter.GlobalFilters, + ServiceName, + async () => await databaseFallback() as List, + debugLabel: "Global IP filters"); + + return result ?? new List(); } /// /// Get IP filters for a specific virtual key from cache with database fallback /// public async Task> GetVirtualKeyFiltersAsync( - int virtualKeyId, + int virtualKeyId, Func>> databaseFallback) { - var cacheKey = VirtualKeyFilterPrefix + virtualKeyId; - - try - { - var cachedValue = await _database.StringGetAsync(cacheKey); - - if (cachedValue.HasValue) - { - var jsonString = (string?)cachedValue; - if (jsonString is not null) - { - var filters = JsonSerializer.Deserialize>(jsonString, _jsonOptions); - - if (filters != null) - { - _logger.LogDebug("Virtual key IP filters cache hit for key {VirtualKeyId} ({Count} filters)", - virtualKeyId, filters.Count); - await _database.StringIncrementAsync(STATS_HIT_KEY); - return filters; - } - } - } - - // Cache miss - fallback to database - _logger.LogDebug("Virtual key IP filters cache miss for key {VirtualKeyId}, querying database", virtualKeyId); - await _database.StringIncrementAsync(STATS_MISS_KEY); - - var dbFilters = await databaseFallback(virtualKeyId); - - if (dbFilters != null) - { - // Cache the filters - await SetVirtualKeyFiltersAsync(virtualKeyId, dbFilters); - return dbFilters; - } - - return new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error accessing virtual key IP filters cache for key {VirtualKeyId}, falling back to database", - virtualKeyId); - await _database.StringIncrementAsync(STATS_MISS_KEY); - return await databaseFallback(virtualKeyId) ?? new List(); - } + var cacheKey = CacheKeys.IpFilter.ByVirtualKey(virtualKeyId); + + var result = await GetOrFallbackAsync>( + cacheKey, + ServiceName, + async () => await databaseFallback(virtualKeyId) as List, + debugLabel: $"Virtual key IP filters for key {virtualKeyId}"); + + return result ?? new List(); } /// /// Check if an IP address is allowed for a virtual key /// public async Task IsIpAllowedAsync( - string ipAddress, - int? virtualKeyId, + string ipAddress, + int? virtualKeyId, Func> databaseFallback) { - var cacheKey = IpCheckPrefix + ipAddress + (virtualKeyId.HasValue ? $":{virtualKeyId}" : ":global"); - + var cacheKey = CacheKeys.IpFilter.CheckResult(ipAddress, virtualKeyId); + try { // Try cached result first - var cachedValue = await _database.StringGetAsync(cacheKey); - + var cachedValue = await Database.StringGetAsync(cacheKey); + if (cachedValue.HasValue) { - _logger.LogDebug("IP check cache hit for {IP} (key: {VirtualKeyId})", ipAddress, virtualKeyId); - await _database.StringIncrementAsync(STATS_HIT_KEY); - await _database.StringIncrementAsync(STATS_IP_CHECK_KEY); + Logger.LogDebug("IP check cache hit for {IP} (key: {VirtualKeyId})", ipAddress, virtualKeyId); + await TrackHitAsync(ServiceName); + await Database.StringIncrementAsync(CacheKeys.Stats.IpChecks()); return cachedValue == "1"; } - + // Cache miss - perform check - _logger.LogDebug("IP check cache miss for {IP} (key: {VirtualKeyId}), performing check", ipAddress, virtualKeyId); - await _database.StringIncrementAsync(STATS_MISS_KEY); - + Logger.LogDebug("IP check cache miss for {IP} (key: {VirtualKeyId}), performing check", ipAddress, virtualKeyId); + await TrackMissAsync(ServiceName); + var isAllowed = await databaseFallback(ipAddress, virtualKeyId); - + // Cache the result with shorter expiry for IP checks - await _database.StringSetAsync(cacheKey, isAllowed ? "1" : "0", TimeSpan.FromMinutes(15)); - await _database.StringIncrementAsync(STATS_IP_CHECK_KEY); - + await Database.StringSetAsync(cacheKey, isAllowed ? "1" : "0", TimeSpan.FromMinutes(15)); + await Database.StringIncrementAsync(CacheKeys.Stats.IpChecks()); + return isAllowed; } catch (Exception ex) { - _logger.LogError(ex, "Error checking IP in cache for {IP} (key: {VirtualKeyId}), falling back to database", + Logger.LogError(ex, "Error checking IP in cache for {IP} (key: {VirtualKeyId}), falling back to database", ipAddress, virtualKeyId); - await _database.StringIncrementAsync(STATS_MISS_KEY); + await TrackMissAsync(ServiceName); return await databaseFallback(ipAddress, virtualKeyId); } } @@ -193,31 +105,21 @@ public async Task InvalidateFilterAsync(int filterId) { try { - // We need to invalidate all caches that might contain this filter - // This includes global filters, virtual key filters, and IP check results - // Clear all IP check results as they might be affected await ClearIpCheckResults(); - - // Since we don't know if it's global or key-specific without querying, - // we'll need to check both caches + + // Invalidate global filters await InvalidateGlobalFiltersAsync(); - - // For virtual key filters, we'd need to scan all keys - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var vkeyFilterKeys = server.Keys(pattern: VirtualKeyFilterPrefix + "*"); - - foreach (var key in vkeyFilterKeys) - { - await _database.KeyDeleteAsync(key); - } - - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - _logger.LogInformation("IP filter cache invalidated for filter ID: {FilterId}", filterId); + + // For virtual key filters, scan and delete all + await ClearAllByPatternAsync(CacheKeys.IpFilter.VirtualKeyPrefix + "*"); + + await TrackInvalidationAsync(ServiceName); + Logger.LogDebug("IP filter cache invalidated for filter ID: {FilterId}", filterId); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating IP filter cache: {FilterId}", filterId); + Logger.LogError(ex, "Error invalidating IP filter cache: {FilterId}", filterId); } } @@ -228,15 +130,15 @@ public async Task InvalidateGlobalFiltersAsync() { try { - await _database.KeyDeleteAsync(GlobalFiltersKey); + await Database.KeyDeleteAsync(CacheKeys.IpFilter.GlobalFilters); await ClearIpCheckResults(); // IP checks depend on filters - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - - _logger.LogInformation("Global IP filters cache invalidated"); + await TrackInvalidationAsync(ServiceName); + + Logger.LogDebug("Global IP filters cache invalidated"); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating global IP filters cache"); + Logger.LogError(ex, "Error invalidating global IP filters cache"); } } @@ -247,24 +149,18 @@ public async Task InvalidateVirtualKeyFiltersAsync(int virtualKeyId) { try { - var cacheKey = VirtualKeyFilterPrefix + virtualKeyId; - await _database.KeyDeleteAsync(cacheKey); - + var cacheKey = CacheKeys.IpFilter.ByVirtualKey(virtualKeyId); + await Database.KeyDeleteAsync(cacheKey); + // Clear IP check results for this virtual key - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var ipCheckKeys = server.Keys(pattern: IpCheckPrefix + $"*:{virtualKeyId}"); - - foreach (var key in ipCheckKeys) - { - await _database.KeyDeleteAsync(key); - } - - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - _logger.LogInformation("Virtual key IP filters cache invalidated for key: {VirtualKeyId}", virtualKeyId); + await ClearAllByPatternAsync(CacheKeys.IpFilter.CheckPrefix + $"*:{virtualKeyId}"); + + await TrackInvalidationAsync(ServiceName); + Logger.LogDebug("Virtual key IP filters cache invalidated for key: {VirtualKeyId}", virtualKeyId); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating virtual key IP filters cache: {VirtualKeyId}", virtualKeyId); + Logger.LogError(ex, "Error invalidating virtual key IP filters cache: {VirtualKeyId}", virtualKeyId); } } @@ -275,20 +171,12 @@ public async Task ClearAllFiltersAsync() { try { - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - - // Clear all filter caches - var filterKeys = server.Keys(pattern: "ipfilter:*"); - foreach (var key in filterKeys) - { - await _database.KeyDeleteAsync(key); - } - - _logger.LogWarning("All IP filter cache entries cleared"); + await ClearAllByPatternAsync("ipfilter:*"); + Logger.LogWarning("All IP filter cache entries cleared"); } catch (Exception ex) { - _logger.LogError(ex, "Error clearing all IP filter cache entries"); + Logger.LogError(ex, "Error clearing all IP filter cache entries"); } } @@ -299,19 +187,16 @@ public async Task GetStatsAsync() { try { - var hits = await _database.StringGetAsync(STATS_HIT_KEY); - var misses = await _database.StringGetAsync(STATS_MISS_KEY); - var invalidations = await _database.StringGetAsync(STATS_INVALIDATION_KEY); - var ipChecks = await _database.StringGetAsync(STATS_IP_CHECK_KEY); - var resetTime = await _database.StringGetAsync(STATS_RESET_TIME_KEY); - - // Count entries - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); + var (hits, misses, invalidations, resetTime) = await GetBaseStatsAsync(ServiceName); + var ipChecks = await Database.StringGetAsync(CacheKeys.Stats.IpChecks()); + + // Count entries with category breakdown + var server = Database.Multiplexer.GetServer(Database.Multiplexer.GetEndPoints()[0]); var filterKeys = server.Keys(pattern: "ipfilter:*"); var entryCount = 0L; var globalCount = 0L; var keySpecificCount = 0L; - + foreach (var key in filterKeys) { entryCount++; @@ -321,14 +206,14 @@ public async Task GetStatsAsync() else if (keyString?.Contains(":vkey:") == true) keySpecificCount++; } - + return new IpFilterCacheStats { - HitCount = hits.HasValue ? (long)hits : 0, - MissCount = misses.HasValue ? (long)misses : 0, - InvalidationCount = invalidations.HasValue ? (long)invalidations : 0, + HitCount = hits, + MissCount = misses, + InvalidationCount = invalidations, IpCheckCount = ipChecks.HasValue ? (long)ipChecks : 0, - LastResetTime = resetTime.HasValue && DateTime.TryParse(resetTime, out var time) ? time : DateTime.UtcNow, + LastResetTime = resetTime, EntryCount = entryCount, GlobalFilterCount = globalCount, KeySpecificFilterCount = keySpecificCount @@ -336,47 +221,22 @@ public async Task GetStatsAsync() } catch (Exception ex) { - _logger.LogError(ex, "Error getting IP filter cache statistics"); + Logger.LogError(ex, "Error getting IP filter cache statistics"); return new IpFilterCacheStats { LastResetTime = DateTime.UtcNow }; } } - private async Task SetGlobalFiltersAsync(List filters) - { - var serialized = JsonSerializer.Serialize(filters, _jsonOptions); - await _database.StringSetAsync(GlobalFiltersKey, serialized, _defaultExpiry); - - _logger.LogDebug("Global IP filters cached ({Count} filters)", filters.Count); - } - - private async Task SetVirtualKeyFiltersAsync(int virtualKeyId, List filters) - { - var cacheKey = VirtualKeyFilterPrefix + virtualKeyId; - var serialized = JsonSerializer.Serialize(filters, _jsonOptions); - await _database.StringSetAsync(cacheKey, serialized, _defaultExpiry); - - _logger.LogDebug("Virtual key IP filters cached for key {VirtualKeyId} ({Count} filters)", - virtualKeyId, filters.Count); - } - private async Task ClearIpCheckResults() { try { - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var ipCheckKeys = server.Keys(pattern: IpCheckPrefix + "*"); - - foreach (var key in ipCheckKeys) - { - await _database.KeyDeleteAsync(key); - } - - _logger.LogDebug("IP check cache results cleared"); + await ClearAllByPatternAsync(CacheKeys.IpFilter.CheckPrefix + "*"); + Logger.LogDebug("IP check cache results cleared"); } catch (Exception ex) { - _logger.LogError(ex, "Error clearing IP check cache results"); + Logger.LogError(ex, "Error clearing IP check cache results"); } } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Helpers.cs b/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Helpers.cs index 147ef6f3a..cb09285ab 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Helpers.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Helpers.cs @@ -1,6 +1,7 @@ using System.Text.Json; using StackExchange.Redis; using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; @@ -20,86 +21,73 @@ public async Task GetStatsAsync() { try { - var hits = await _database.StringGetAsync(STATS_HIT_KEY); - var misses = await _database.StringGetAsync(STATS_MISS_KEY); - var invalidations = await _database.StringGetAsync(STATS_INVALIDATION_KEY); - var patternMatches = await _database.StringGetAsync(STATS_PATTERN_MATCH_KEY); - var resetTime = await _database.StringGetAsync(STATS_RESET_TIME_KEY); - - // Count entries - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - var entryCount = 0L; - foreach (var _ in keys) - { - entryCount++; - } - + var (hits, misses, invalidations, resetTime) = await GetBaseStatsAsync(ServiceName); + var patternMatches = await Database.StringGetAsync(CacheKeys.Stats.PatternMatches()); + var pendingPatternMatches = Interlocked.Read(ref _bufferedPatternMatches); + return new ModelCostCacheStats { - HitCount = hits.HasValue ? (long)hits : 0, - MissCount = misses.HasValue ? (long)misses : 0, - InvalidationCount = invalidations.HasValue ? (long)invalidations : 0, - PatternMatchCount = patternMatches.HasValue ? (long)patternMatches : 0, - LastResetTime = resetTime.HasValue && DateTime.TryParse(resetTime, out var time) ? time : DateTime.UtcNow, - EntryCount = entryCount + HitCount = hits + PendingHits, + MissCount = misses + PendingMisses, + InvalidationCount = invalidations + PendingInvalidations, + PatternMatchCount = (patternMatches.HasValue ? (long)patternMatches : 0) + pendingPatternMatches, + LastResetTime = resetTime, + EntryCount = CountEntries(CacheKeys.ModelCost.Prefix + "*") }; } catch (Exception ex) { - _logger.LogError(ex, "Error getting model cost cache statistics"); + Logger.LogError(ex, "Error getting model cost cache statistics"); return new ModelCostCacheStats { LastResetTime = DateTime.UtcNow }; } } private async Task SetModelCostAsync(ModelCost cost) { - var patternKey = PatternKeyPrefix + cost.CostName.ToLowerInvariant(); - + var patternKey = CacheKeys.ModelCost.PatternPrefix + cost.CostName.ToLowerInvariant(); + // Create cached version with pre-parsed configuration var cachedCost = ConvertToCachedModelCost(cost); - var serialized = JsonSerializer.Serialize(cachedCost, _jsonOptions); - - await _database.StringSetAsync(patternKey, serialized, _defaultExpiry); - - _logger.LogDebug("Model cost cached for cost name: {CostName}", cost.CostName); - } + await SetCacheEntryAsync(patternKey, cachedCost); - /* - private async Task SetProviderModelCostsAsync(string providerName, List costs) - { - var providerKey = ProviderKeyPrefix + providerName.ToLowerInvariant(); - var serialized = JsonSerializer.Serialize(costs, _jsonOptions); - - await _database.StringSetAsync(providerKey, serialized, _defaultExpiry); - - _logger.LogDebug("Model costs cached for provider: {Provider} ({Count} costs)", providerName, costs.Count()); + Logger.LogDebug("Model cost cached for cost name: {CostName}", cost.CostName); } - */ /// /// Handle single invalidation messages from other instances /// - private async void OnCostInvalidated(RedisChannel channel, RedisValue costId) + private void OnCostInvalidated(RedisChannel channel, RedisValue costId) + { + // Fire-and-forget with proper exception handling - don't use async void + _ = OnCostInvalidatedAsync(costId); + } + + private async Task OnCostInvalidatedAsync(RedisValue costId) { try { if (int.TryParse(costId.ToString(), out var id)) { await InvalidateModelCostAsync(id); - _logger.LogDebug("Invalidated model cost from pub/sub: {CostId}", id); + Logger.LogDebug("Invalidated model cost from pub/sub: {CostId}", id); } } catch (Exception ex) { - _logger.LogError(ex, "Error handling cost invalidation: {CostId}", costId.ToString()); + Logger.LogError(ex, "Error handling cost invalidation: {CostId}", costId.ToString()); } } /// /// Handle batch invalidation messages from other instances /// - private async void OnBatchInvalidated(RedisChannel channel, RedisValue message) + private void OnBatchInvalidated(RedisChannel channel, RedisValue message) + { + // Fire-and-forget with proper exception handling - don't use async void + _ = OnBatchInvalidatedAsync(message); + } + + private async Task OnBatchInvalidatedAsync(RedisValue message) { try { @@ -112,17 +100,17 @@ private async void OnBatchInvalidated(RedisChannel channel, RedisValue message) EntityId = id, Reason = "Batch invalidation from pub/sub" }); - + await InvalidateBatchAsync(requests); - - _logger.LogDebug( + + Logger.LogDebug( "Batch invalidated {Count} model costs from pub/sub", batchMessage.CostIds.Length); } } catch (Exception ex) { - _logger.LogError(ex, "Error handling batch cost invalidation"); + Logger.LogError(ex, "Error handling batch cost invalidation"); } } @@ -157,17 +145,17 @@ private CachedModelCost ConvertToCachedModelCost(ModelCost cost) { cached.ParsedPricingConfiguration = cost.PricingModel switch { - PricingModel.PerVideo => JsonSerializer.Deserialize(cost.PricingConfiguration, _jsonOptions), - PricingModel.PerSecondVideo => JsonSerializer.Deserialize(cost.PricingConfiguration, _jsonOptions), - PricingModel.InferenceSteps => JsonSerializer.Deserialize(cost.PricingConfiguration, _jsonOptions), - PricingModel.TieredTokens => JsonSerializer.Deserialize(cost.PricingConfiguration, _jsonOptions), - PricingModel.PerImage => JsonSerializer.Deserialize(cost.PricingConfiguration, _jsonOptions), + PricingModel.PerVideo => JsonSerializer.Deserialize(cost.PricingConfiguration, JsonOptions), + PricingModel.PerSecondVideo => JsonSerializer.Deserialize(cost.PricingConfiguration, JsonOptions), + PricingModel.InferenceSteps => JsonSerializer.Deserialize(cost.PricingConfiguration, JsonOptions), + PricingModel.TieredTokens => JsonSerializer.Deserialize(cost.PricingConfiguration, JsonOptions), + PricingModel.PerImage => JsonSerializer.Deserialize(cost.PricingConfiguration, JsonOptions), _ => null }; } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to parse PricingConfiguration for cost {CostName} with model {PricingModel}", + Logger.LogWarning(ex, "Failed to parse PricingConfiguration for cost {CostName} with model {PricingModel}", cost.CostName, cost.PricingModel); } } @@ -175,4 +163,4 @@ private CachedModelCost ConvertToCachedModelCost(ModelCost cost) return cached; } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Invalidation.cs b/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Invalidation.cs index b402a8a50..ea0b29f0e 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Invalidation.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.Invalidation.cs @@ -1,5 +1,6 @@ using System.Text.Json; using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; @@ -18,13 +19,12 @@ public async Task InvalidateModelCostAsync(int modelCostId) try { // We need to find and invalidate all keys related to this model cost - // This includes the pattern key and any exact match keys - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - + var server = Database.Multiplexer.GetServer(Database.Multiplexer.GetEndPoints()[0]); + var keys = server.Keys(pattern: CacheKeys.ModelCost.Prefix + "*"); + foreach (var key in keys) { - var value = await _database.StringGetAsync(key); + var value = await Database.StringGetAsync(key); if (value.HasValue) { try @@ -32,13 +32,10 @@ public async Task InvalidateModelCostAsync(int modelCostId) var jsonString = (string?)value; if (jsonString != null) { - var cost = JsonSerializer.Deserialize(jsonString, _jsonOptions); + var cost = JsonSerializer.Deserialize(jsonString, JsonOptions); if (cost?.Id == modelCostId) { - await _database.KeyDeleteAsync(key); - - // Note: Provider information is not stored in ModelCost entity - // Provider-specific invalidation would require additional context + await Database.KeyDeleteAsync(key); } } } @@ -48,60 +45,15 @@ public async Task InvalidateModelCostAsync(int modelCostId) } } } - - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - _logger.LogInformation("Model cost cache invalidated for ID: {ModelCostId}", modelCostId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error invalidating Model Cost cache: {ModelCostId}", modelCostId); - } - } - /* - /// - /// Invalidate all Model Costs for a provider - /// NOTE: This method is disabled as ModelCost entity doesn't contain provider information - /// - public async Task InvalidateProviderModelCostsAsync(string providerName) - { - try - { - // Invalidate the provider costs list - var providerKey = ProviderKeyPrefix + providerName.ToLowerInvariant(); - await _database.KeyDeleteAsync(providerKey); - - // Also invalidate individual cost entries for this provider - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: PatternKeyPrefix + "*"); - - foreach (var key in keys) - { - var value = await _database.StringGetAsync(key); - if (value.HasValue) - { - try - { - // Note: Provider information is not stored in ModelCost entity - // This method would need to be rethought or removed - // For now, we'll skip individual invalidation since we can't determine provider - } - catch - { - // Skip malformed entries - } - } - } - - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - _logger.LogInformation("Model costs cache invalidated for provider: {Provider}", providerName); + await TrackInvalidationAsync(ServiceName); + Logger.LogDebug("Model cost cache invalidated for ID: {ModelCostId}", modelCostId); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating provider Model Costs cache: {Provider}", providerName); + Logger.LogError(ex, "Error invalidating Model Cost cache: {ModelCostId}", modelCostId); } } - */ /// /// Invalidate Model Cost by pattern @@ -110,28 +62,28 @@ public async Task InvalidateModelCostByPatternAsync(string modelIdPattern) { try { - var patternKey = PatternKeyPrefix + modelIdPattern.ToLowerInvariant(); - await _database.KeyDeleteAsync(patternKey); - + var patternKey = CacheKeys.ModelCost.PatternPrefix + modelIdPattern.ToLowerInvariant(); + await Database.KeyDeleteAsync(patternKey); + // Also invalidate any exact match keys that might be affected - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: PatternKeyPrefix + "*"); - + var server = Database.Multiplexer.GetServer(Database.Multiplexer.GetEndPoints()[0]); + var keys = server.Keys(pattern: CacheKeys.ModelCost.PatternPrefix + "*"); + foreach (var key in keys) { var keyString = key.ToString(); if (keyString != null && keyString.Contains(modelIdPattern, StringComparison.OrdinalIgnoreCase)) { - await _database.KeyDeleteAsync(key); + await Database.KeyDeleteAsync(key); } } - - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - _logger.LogInformation("Model cost cache invalidated for pattern: {Pattern}", modelIdPattern); + + await TrackInvalidationAsync(ServiceName); + Logger.LogDebug("Model cost cache invalidated for pattern: {Pattern}", modelIdPattern); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating Model Cost cache by pattern: {Pattern}", modelIdPattern); + Logger.LogError(ex, "Error invalidating Model Cost cache by pattern: {Pattern}", modelIdPattern); } } @@ -142,19 +94,12 @@ public async Task ClearAllModelCostsAsync() { try { - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - - foreach (var key in keys) - { - await _database.KeyDeleteAsync(key); - } - - _logger.LogWarning("All model cost cache entries cleared"); + await ClearAllByPatternAsync(CacheKeys.ModelCost.Prefix + "*"); + Logger.LogWarning("All model cost cache entries cleared"); } catch (Exception ex) { - _logger.LogError(ex, "Error clearing all model cost cache entries"); + Logger.LogError(ex, "Error clearing all model cost cache entries"); } } @@ -162,18 +107,18 @@ public async Task ClearAllModelCostsAsync() /// Batch invalidate multiple model costs /// public async Task InvalidateBatchAsync( - IEnumerable requests, + IEnumerable requests, CancellationToken cancellationToken = default) { var costIds = requests .Where(r => r.EntityType == CacheType.ModelCost.ToString()) .Select(r => r.EntityId) .ToArray(); - + if (costIds.Length == 0) { - return new BatchInvalidationResult - { + return new BatchInvalidationResult + { Success = true, ProcessedCount = 0, Duration = TimeSpan.Zero @@ -181,28 +126,26 @@ public async Task InvalidateBatchAsync( } var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - + try { // Use Redis pipeline for batch delete - var batch = _database.CreateBatch(); + var batch = Database.CreateBatch(); var deleteTasks = new List>(); var keysToDelete = new List(); - + // For each cost ID, we need to find all related keys foreach (var costId in costIds) { - // Since we're working with pattern-based cache, we need to scan for keys - // This is less efficient than direct key deletion but necessary for pattern matching if (int.TryParse(costId, out var id)) { // Direct invalidation by ID - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - + var server = Database.Multiplexer.GetServer(Database.Multiplexer.GetEndPoints()[0]); + var keys = server.Keys(pattern: CacheKeys.ModelCost.Prefix + "*"); + foreach (var key in keys) { - var value = await _database.StringGetAsync(key); + var value = await Database.StringGetAsync(key); if (value.HasValue) { try @@ -210,7 +153,7 @@ public async Task InvalidateBatchAsync( var jsonString = (string?)value; if (jsonString != null) { - var cost = JsonSerializer.Deserialize(jsonString, _jsonOptions); + var cost = JsonSerializer.Deserialize(jsonString, JsonOptions); if (cost?.Id == id) { keysToDelete.Add(key.ToString()!); @@ -227,41 +170,41 @@ public async Task InvalidateBatchAsync( else { // Pattern-based invalidation - keysToDelete.Add(PatternKeyPrefix + costId.ToLowerInvariant()); + keysToDelete.Add(CacheKeys.ModelCost.PatternPrefix + costId.ToLowerInvariant()); } } - + // Delete all found keys in batch foreach (var key in keysToDelete) { deleteTasks.Add(batch.KeyDeleteAsync(key)); } - + // Execute batch batch.Execute(); await Task.WhenAll(deleteTasks); - + // Update invalidation statistics - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY, keysToDelete.Count()); - + await TrackInvalidationAsync(ServiceName, keysToDelete.Count); + // Publish batch invalidation message to other instances var batchMessage = new ModelCostBatchInvalidation { CostIds = costIds, Timestamp = DateTime.UtcNow }; - + await _subscriber.PublishAsync( - RedisChannel.Literal(BatchInvalidationChannel), + RedisChannel.Literal(CacheKeys.ModelCost.BatchInvalidationChannel), JsonSerializer.Serialize(batchMessage)); - + stopwatch.Stop(); - - _logger.LogInformation( + + Logger.LogInformation( "Batch invalidated {Count} model costs in {Duration}ms", - costIds.Length, + costIds.Length, stopwatch.ElapsedMilliseconds); - + return new BatchInvalidationResult { Success = true, @@ -272,8 +215,8 @@ await _subscriber.PublishAsync( catch (Exception ex) { stopwatch.Stop(); - _logger.LogError(ex, "Failed to batch invalidate model costs"); - + Logger.LogError(ex, "Failed to batch invalidate model costs"); + return new BatchInvalidationResult { Success = false, @@ -293,4 +236,4 @@ private class ModelCostBatchInvalidation public DateTime Timestamp { get; set; } } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.cs b/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.cs index af011e64c..d7824e41d 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisModelCostCache.cs @@ -1,224 +1,209 @@ using System.Text.Json; using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; +using ConduitLLM.Gateway.Metrics; namespace ConduitLLM.Gateway.Services { /// /// Redis-based Model Cost cache with event-driven invalidation /// - public partial class RedisModelCostCache : IModelCostCache, IBatchInvalidatable + public partial class RedisModelCostCache : BufferedStatsRedisCacheBase, IModelCostCache, IBatchInvalidatable { - private readonly IDatabase _database; - private readonly ILogger _logger; - private readonly TimeSpan _defaultExpiry = TimeSpan.FromHours(6); // Model costs change infrequently - private const string KeyPrefix = "modelcost:"; - private const string PatternKeyPrefix = "modelcost:pattern:"; - private const string ProviderKeyPrefix = "modelcost:provider:"; - - // Statistics tracking keys - private const string STATS_HIT_KEY = "conduit:cache:modelcost:stats:hits"; - private const string STATS_MISS_KEY = "conduit:cache:modelcost:stats:misses"; - private const string STATS_INVALIDATION_KEY = "conduit:cache:modelcost:stats:invalidations"; - private const string STATS_RESET_TIME_KEY = "conduit:cache:modelcost:stats:reset_time"; - private const string STATS_PATTERN_MATCH_KEY = "conduit:cache:modelcost:stats:pattern_matches"; - - private const string InvalidationChannel = "mcost_invalidated"; - private const string BatchInvalidationChannel = "mcost_batch_invalidated"; + private readonly IDistributedCachePopulator _cachePopulator; private readonly ISubscriber _subscriber; - private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; + protected override string ServiceName => CacheKeys.Stats.ModelCostService; + + // Custom counter for pattern match lookups (beyond standard hits/misses/invalidations) + private long _bufferedPatternMatches; public RedisModelCostCache( IConnectionMultiplexer redis, - ILogger logger) + ILogger logger, + IDistributedCachePopulator cachePopulator) + : base(redis, logger, TimeSpan.FromHours(6)) { - _database = redis.GetDatabase(); + _cachePopulator = cachePopulator; _subscriber = redis.GetSubscriber(); - _logger = logger; - - // Initialize stats reset time if not exists - _database.StringSetAsync(STATS_RESET_TIME_KEY, DateTime.UtcNow.ToString("O"), when: When.NotExists).GetAwaiter().GetResult(); - + // Subscribe to invalidation messages - _subscriber.Subscribe(RedisChannel.Literal(InvalidationChannel), OnCostInvalidated); - _subscriber.Subscribe(RedisChannel.Literal(BatchInvalidationChannel), OnBatchInvalidated); + _subscriber.Subscribe(RedisChannel.Literal(CacheKeys.ModelCost.InvalidationChannel), OnCostInvalidated); + _subscriber.Subscribe(RedisChannel.Literal(CacheKeys.ModelCost.BatchInvalidationChannel), OnBatchInvalidated); } /// /// Get Model Cost by pattern from cache with database fallback /// public async Task GetModelCostByPatternAsync( - string modelIdPattern, + string modelIdPattern, Func> databaseFallback) { - var cacheKey = PatternKeyPrefix + modelIdPattern.ToLowerInvariant(); - + var cacheKey = CacheKeys.ModelCost.PatternPrefix + modelIdPattern.ToLowerInvariant(); + try { - var cachedValue = await _database.StringGetAsync(cacheKey); - + var cachedValue = await Database.StringGetAsync(cacheKey); + if (cachedValue.HasValue) { var jsonString = (string?)cachedValue; if (jsonString is not null) { - var cost = JsonSerializer.Deserialize(jsonString, _jsonOptions); - + var cost = JsonSerializer.Deserialize(jsonString, JsonOptions); + if (cost != null) { - _logger.LogDebug("Model cost cache hit for pattern: {Pattern}", modelIdPattern); - await _database.StringIncrementAsync(STATS_HIT_KEY); + Logger.LogDebug("Model cost cache hit for pattern: {Pattern}", modelIdPattern); + Interlocked.Increment(ref _bufferedPatternMatches); + await TrackHitAsync(ServiceName); + GatewayCacheMetrics.RecordHit("modelcost"); return cost; } } } - - // Cache miss - fallback to database - _logger.LogDebug("Model cost cache miss for pattern, querying database: {Pattern}", modelIdPattern); - await _database.StringIncrementAsync(STATS_MISS_KEY); - - var dbCost = await databaseFallback(modelIdPattern); - + + // Cache miss - use stampede prevention to avoid multiple concurrent DB queries + Logger.LogDebug("Model cost cache miss for pattern, querying database: {Pattern}", modelIdPattern); + await TrackMissAsync(ServiceName); + GatewayCacheMetrics.RecordMiss("modelcost"); + + var dbCost = await _cachePopulator.GetOrPopulateAsync( + lockKey: $"populate:modelcost:pattern:{modelIdPattern.ToLowerInvariant()}", + cacheCheck: async () => + { + // Re-check cache in case another instance populated it + var cached = await Database.StringGetAsync(cacheKey); + if (cached.HasValue) + { + var jsonStr = (string?)cached; + if (jsonStr is not null) + { + return JsonSerializer.Deserialize(jsonStr, JsonOptions); + } + } + return null; + }, + factory: () => databaseFallback(modelIdPattern)); + if (dbCost != null) { // Cache the cost await SetModelCostAsync(dbCost); return dbCost; } - + return null; } catch (Exception ex) { - _logger.LogError(ex, "Error accessing Model Cost cache for pattern, falling back to database: {Pattern}", modelIdPattern); - await _database.StringIncrementAsync(STATS_MISS_KEY); + Logger.LogError(ex, "Error accessing Model Cost cache for pattern, falling back to database: {Pattern}", modelIdPattern); + await TrackMissAsync(ServiceName); + GatewayCacheMetrics.RecordError("modelcost", "get"); return await databaseFallback(modelIdPattern); } } - /* - /// - /// Get all Model Costs for a provider from cache with database fallback - /// NOTE: This method is disabled as ModelCost entity doesn't contain provider information - /// - public async Task> GetProviderModelCostsAsync( - string providerName, - Func>> databaseFallback) - { - var cacheKey = ProviderKeyPrefix + providerName.ToLowerInvariant(); - - try - { - var cachedValue = await _database.StringGetAsync(cacheKey); - - if (cachedValue.HasValue) - { - var jsonString = (string?)cachedValue; - if (jsonString is not null) - { - var costs = JsonSerializer.Deserialize>(jsonString, _jsonOptions); - - if (costs != null) - { - _logger.LogDebug("Model costs cache hit for provider: {Provider}", providerName); - await _database.StringIncrementAsync(STATS_HIT_KEY); - return costs; - } - } - } - - // Cache miss - fallback to database - _logger.LogDebug("Model costs cache miss for provider, querying database: {Provider}", providerName); - await _database.StringIncrementAsync(STATS_MISS_KEY); - - var dbCosts = await databaseFallback(providerName); - - if (dbCosts != null && dbCosts.Count() > 0) - { - // NOTE: Provider-based caching disabled as ModelCost doesn't contain provider info - // await SetProviderModelCostsAsync(providerName, dbCosts); - - // Also cache individual costs by pattern - foreach (var cost in dbCosts) - { - await SetModelCostAsync(cost); - } - - return dbCosts; - } - - return dbCosts ?? new List(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error accessing Model Costs cache for provider, falling back to database: {Provider}", providerName); - await _database.StringIncrementAsync(STATS_MISS_KEY); - return await databaseFallback(providerName) ?? new List(); - } - } - */ - /// /// Get Model Cost for a specific model ID by finding best matching pattern /// public async Task GetModelCostForModelIdAsync( - string modelId, + string modelId, Func> databaseFallback) { try { // Try exact match first - var exactKey = PatternKeyPrefix + modelId.ToLowerInvariant(); - var cachedValue = await _database.StringGetAsync(exactKey); - + var exactKey = CacheKeys.ModelCost.PatternPrefix + modelId.ToLowerInvariant(); + var cachedValue = await Database.StringGetAsync(exactKey); + if (cachedValue.HasValue) { var jsonString = (string?)cachedValue; if (jsonString is not null) { - var cost = JsonSerializer.Deserialize(jsonString, _jsonOptions); + var cost = JsonSerializer.Deserialize(jsonString, JsonOptions); if (cost != null) { - _logger.LogDebug("Model cost cache hit for exact model ID: {ModelId}", modelId); - await _database.StringIncrementAsync(STATS_HIT_KEY); - await _database.StringIncrementAsync(STATS_PATTERN_MATCH_KEY); + Logger.LogDebug("Model cost cache hit for exact model ID: {ModelId}", modelId); + Interlocked.Increment(ref _bufferedPatternMatches); + await TrackHitAsync(ServiceName); + GatewayCacheMetrics.RecordHit("modelcost"); return cost; } } } - - // If no exact match, fall back to database for pattern matching - _logger.LogDebug("Model cost cache miss for model ID, querying database for pattern match: {ModelId}", modelId); - await _database.StringIncrementAsync(STATS_MISS_KEY); - - var dbCost = await databaseFallback(modelId); - + + // If no exact match, use stampede prevention to avoid multiple concurrent DB queries + Logger.LogDebug("Model cost cache miss for model ID, querying database for pattern match: {ModelId}", modelId); + await TrackMissAsync(ServiceName); + GatewayCacheMetrics.RecordMiss("modelcost"); + + var dbCost = await _cachePopulator.GetOrPopulateAsync( + lockKey: $"populate:modelcost:modelid:{modelId.ToLowerInvariant()}", + cacheCheck: async () => + { + // Re-check cache in case another instance populated it + var cached = await Database.StringGetAsync(exactKey); + if (cached.HasValue) + { + var jsonStr = (string?)cached; + if (jsonStr is not null) + { + return JsonSerializer.Deserialize(jsonStr, JsonOptions); + } + } + return null; + }, + factory: () => databaseFallback(modelId)); + if (dbCost != null) { // Cache the result with the exact model ID for faster future lookups - var serialized = JsonSerializer.Serialize(dbCost, _jsonOptions); - await _database.StringSetAsync(exactKey, serialized, _defaultExpiry); - await _database.StringIncrementAsync(STATS_PATTERN_MATCH_KEY); - + var serialized = JsonSerializer.Serialize(dbCost, JsonOptions); + await Database.StringSetAsync(exactKey, serialized, DefaultExpiry); + Interlocked.Increment(ref _bufferedPatternMatches); + return dbCost; } - + return null; } catch (Exception ex) { - _logger.LogError(ex, "Error accessing Model Cost cache for model ID, falling back to database: {ModelId}", modelId); - await _database.StringIncrementAsync(STATS_MISS_KEY); + Logger.LogError(ex, "Error accessing Model Cost cache for model ID, falling back to database: {ModelId}", modelId); + await TrackMissAsync(ServiceName); + GatewayCacheMetrics.RecordError("modelcost", "get"); return await databaseFallback(modelId); } } + #region Custom PatternMatches Counter + + protected override bool HasPendingCustomStats() + => Interlocked.Read(ref _bufferedPatternMatches) > 0; + + protected override void OnFlush(IBatch batch, List tasks) + { + var patternMatches = Interlocked.Exchange(ref _bufferedPatternMatches, 0); + if (patternMatches > 0) + { + tasks.Add(batch.StringIncrementAsync(CacheKeys.Stats.PatternMatches(), patternMatches)); + } + } + protected override void OnFinalFlush(List tasks) + { + var patternMatches = Interlocked.Exchange(ref _bufferedPatternMatches, 0); + if (patternMatches > 0) + { + tasks.Add(Database.StringIncrementAsync(CacheKeys.Stats.PatternMatches(), patternMatches)); + } + } + #endregion } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/RedisProviderCredentialCache.cs b/Services/ConduitLLM.Gateway/Services/RedisProviderCredentialCache.cs index 452971fbb..8aca8a956 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisProviderCredentialCache.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisProviderCredentialCache.cs @@ -1,92 +1,95 @@ using System.Text.Json; using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; +using ConduitLLM.Core.Services; namespace ConduitLLM.Gateway.Services { /// /// Redis-based Provider Credential cache with event-driven invalidation /// - public class RedisProviderCache : IProviderCache + public class RedisProviderCache : RedisCacheServiceBase, IProviderCache { - private readonly IDatabase _database; - private readonly ILogger _logger; - private readonly TimeSpan _defaultExpiry = TimeSpan.FromHours(1); - private const string KeyPrefix = "provider:"; - private const string NameKeyPrefix = "provider:name:"; // DEPRECATED - only for cleanup - - // Statistics tracking keys - private const string STATS_HIT_KEY = "conduit:cache:provider:stats:hits"; - private const string STATS_MISS_KEY = "conduit:cache:provider:stats:misses"; - private const string STATS_INVALIDATION_KEY = "conduit:cache:provider:stats:invalidations"; - private const string STATS_RESET_TIME_KEY = "conduit:cache:provider:stats:reset_time"; - - private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; + private readonly IDistributedCachePopulator _cachePopulator; + + private static readonly string ServiceName = CacheKeys.Stats.ProviderService; public RedisProviderCache( IConnectionMultiplexer redis, - ILogger logger) + ILogger logger, + IDistributedCachePopulator cachePopulator) + : base(redis, logger, TimeSpan.FromHours(1)) { - _database = redis.GetDatabase(); - _logger = logger; - - // Initialize stats reset time if not exists - _database.StringSetAsync(STATS_RESET_TIME_KEY, DateTime.UtcNow.ToString("O"), when: When.NotExists).GetAwaiter().GetResult(); + _cachePopulator = cachePopulator; + InitializeStatsResetTime(ServiceName); } /// /// Get Provider Credential from cache with database fallback /// public async Task GetProviderAsync( - int providerId, + int providerId, Func> databaseFallback) { - var cacheKey = KeyPrefix + providerId; - + var cacheKey = CacheKeys.Provider.ById(providerId); + try { - var cachedValue = await _database.StringGetAsync(cacheKey); - + var cachedValue = await Database.StringGetAsync(cacheKey); + if (cachedValue.HasValue) { var jsonString = (string?)cachedValue; if (jsonString is not null) { - var credential = JsonSerializer.Deserialize(jsonString, _jsonOptions); - + var credential = JsonSerializer.Deserialize(jsonString, JsonOptions); + if (credential != null) { - _logger.LogDebug("Provider credential cache hit: {ProviderId}", providerId); - await _database.StringIncrementAsync(STATS_HIT_KEY); + Logger.LogDebug("Provider credential cache hit: {ProviderId}", providerId); + await TrackHitAsync(ServiceName); return credential; } } } - - // Cache miss - fallback to database - _logger.LogDebug("Provider credential cache miss, querying database: {ProviderId}", providerId); - await _database.StringIncrementAsync(STATS_MISS_KEY); - - var dbCredential = await databaseFallback(providerId); - + + // Cache miss - use stampede prevention to avoid multiple concurrent DB queries + Logger.LogDebug("Provider credential cache miss, querying database: {ProviderId}", providerId); + await TrackMissAsync(ServiceName); + + var dbCredential = await _cachePopulator.GetOrPopulateAsync( + lockKey: $"populate:provider:{providerId}", + cacheCheck: async () => + { + // Re-check cache in case another instance populated it + var cached = await Database.StringGetAsync(cacheKey); + if (cached.HasValue) + { + var jsonStr = (string?)cached; + if (jsonStr is not null) + { + return JsonSerializer.Deserialize(jsonStr, JsonOptions); + } + } + return null; + }, + factory: () => databaseFallback(providerId)); + if (dbCredential != null) { - // Cache the credential await SetProviderAsync(providerId, dbCredential); return dbCredential; } - + return null; } catch (Exception ex) { - _logger.LogError(ex, "Error accessing Provider Credential cache, falling back to database: {ProviderId}", providerId); - await _database.StringIncrementAsync(STATS_MISS_KEY); + Logger.LogError(ex, "Error accessing Provider Credential cache, falling back to database: {ProviderId}", providerId); + await TrackMissAsync(ServiceName); return await databaseFallback(providerId); } } @@ -95,30 +98,29 @@ public RedisProviderCache( /// Get Provider Credential by name from cache with database fallback /// public async Task GetProviderByNameAsync( - string providerName, + string providerName, Func> databaseFallback) { // Always go to database for name lookups since names can change - // We cannot cache by name as it's mutable try { - _logger.LogDebug("Provider credential lookup by name, querying database: {ProviderName}", providerName); - await _database.StringIncrementAsync(STATS_MISS_KEY); - + Logger.LogDebug("Provider credential lookup by name, querying database: {ProviderName}", providerName); + await TrackMissAsync(ServiceName); + var dbCredential = await databaseFallback(providerName); - + if (dbCredential != null) { // Cache by ID only await SetProviderAsync(dbCredential.Provider.Id, dbCredential); return dbCredential; } - + return null; } catch (Exception ex) { - _logger.LogError(ex, "Error accessing Provider Credential by name, falling back to database: {ProviderName}", providerName); + Logger.LogError(ex, "Error accessing Provider Credential by name, falling back to database: {ProviderName}", providerName); return await databaseFallback(providerName); } } @@ -130,32 +132,32 @@ public async Task InvalidateProviderAsync(int providerId) { try { - var cacheKey = KeyPrefix + providerId; - + var cacheKey = CacheKeys.Provider.ById(providerId); + // Get the provider to find its name for name-based key invalidation - var cachedValue = await _database.StringGetAsync(cacheKey); + var cachedValue = await Database.StringGetAsync(cacheKey); if (cachedValue.HasValue) { var jsonString = (string?)cachedValue; if (jsonString is not null) { - var credential = JsonSerializer.Deserialize(jsonString, _jsonOptions); + var credential = JsonSerializer.Deserialize(jsonString, JsonOptions); if (credential != null) { // No longer using name-based keys } } } - + // Delete ID-based key - await _database.KeyDeleteAsync(cacheKey); - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - - _logger.LogInformation("Provider credential cache invalidated: {ProviderId}", providerId); + await Database.KeyDeleteAsync(cacheKey); + await TrackInvalidationAsync(ServiceName); + + Logger.LogDebug("Provider credential cache invalidated: {ProviderId}", providerId); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating Provider Credential cache: {ProviderId}", providerId); + Logger.LogError(ex, "Error invalidating Provider Credential cache: {ProviderId}", providerId); } } @@ -165,8 +167,7 @@ public async Task InvalidateProviderAsync(int providerId) public Task InvalidateProviderByNameAsync(string providerName) { // Since we don't cache by name anymore, this is a no-op - // We would need the provider ID to invalidate the cache - _logger.LogWarning("InvalidateProviderByNameAsync called but we don't cache by name. Provider: {ProviderName}", providerName); + Logger.LogDebug("InvalidateProviderByNameAsync called but we don't cache by name. Provider: {ProviderName}", providerName); return Task.CompletedTask; } @@ -177,26 +178,16 @@ public async Task ClearAllProvidersAsync() { try { - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - - foreach (var key in keys) - { - await _database.KeyDeleteAsync(key); - } - + await ClearAllByPatternAsync(CacheKeys.Provider.Prefix + "*"); + // Clean up any legacy name-based keys - var nameKeys = server.Keys(pattern: NameKeyPrefix + "*"); - foreach (var key in nameKeys) - { - await _database.KeyDeleteAsync(key); - } - - _logger.LogWarning("All provider credential cache entries cleared"); + await ClearAllByPatternAsync(CacheKeys.Provider.NamePrefix + "*"); + + Logger.LogWarning("All provider credential cache entries cleared"); } catch (Exception ex) { - _logger.LogError(ex, "Error clearing all provider credential cache entries"); + Logger.LogError(ex, "Error clearing all provider credential cache entries"); } } @@ -207,46 +198,31 @@ public async Task GetStatsAsync() { try { - var hits = await _database.StringGetAsync(STATS_HIT_KEY); - var misses = await _database.StringGetAsync(STATS_MISS_KEY); - var invalidations = await _database.StringGetAsync(STATS_INVALIDATION_KEY); - var resetTime = await _database.StringGetAsync(STATS_RESET_TIME_KEY); - - // Count entries - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var keys = server.Keys(pattern: KeyPrefix + "*"); - var entryCount = 0L; - foreach (var _ in keys) - { - entryCount++; - } - + var (hits, misses, invalidations, resetTime) = await GetBaseStatsAsync(ServiceName); + return new ProviderCacheStats { - HitCount = hits.HasValue ? (long)hits : 0, - MissCount = misses.HasValue ? (long)misses : 0, - InvalidationCount = invalidations.HasValue ? (long)invalidations : 0, - LastResetTime = resetTime.HasValue && DateTime.TryParse(resetTime, out var time) ? time : DateTime.UtcNow, - EntryCount = entryCount + HitCount = hits, + MissCount = misses, + InvalidationCount = invalidations, + LastResetTime = resetTime, + EntryCount = CountEntries(CacheKeys.Provider.Prefix + "*") }; } catch (Exception ex) { - _logger.LogError(ex, "Error getting provider credential cache statistics"); + Logger.LogError(ex, "Error getting provider credential cache statistics"); return new ProviderCacheStats { LastResetTime = DateTime.UtcNow }; } } private async Task SetProviderAsync(int providerId, CachedProvider credential) { - var cacheKey = KeyPrefix + providerId; - var serialized = JsonSerializer.Serialize(credential, _jsonOptions); - - // Cache by ID only - never by name since names can change - await _database.StringSetAsync(cacheKey, serialized, _defaultExpiry); - - _logger.LogDebug("Provider credential cached: {ProviderId} with {KeyCount} keys", + var cacheKey = CacheKeys.Provider.ById(providerId); + await SetCacheEntryAsync(cacheKey, credential); + + Logger.LogDebug("Provider credential cached: {ProviderId} with {KeyCount} keys", providerId, credential.Keys.Count); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/RedisProviderToolCache.cs b/Services/ConduitLLM.Gateway/Services/RedisProviderToolCache.cs new file mode 100644 index 000000000..b2c8b2a55 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/RedisProviderToolCache.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; +using StackExchange.Redis; + +namespace ConduitLLM.Gateway.Services; + +/// +/// Redis-backed cache for provider tool lookups in the billing pipeline. +/// Caches active tools per provider type to eliminate per-request database queries. +/// +public class RedisProviderToolCache : BufferedStatsRedisCacheBase, IProviderToolCache +{ + private readonly ISubscriber _subscriber; + + protected override string ServiceName => CacheKeys.Stats.ProviderToolService; + + public RedisProviderToolCache( + IConnectionMultiplexer redis, + ILogger logger) + : base(redis, logger, TimeSpan.FromHours(1), new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }) + { + _subscriber = redis.GetSubscriber(); + + // Subscribe to invalidation channel for cross-instance cache consistency + _subscriber.Subscribe( + RedisChannel.Literal(CacheKeys.ProviderTool.InvalidationChannel), + OnToolInvalidated); + + Logger.LogDebug("RedisProviderToolCache initialized with {Expiry} TTL", DefaultExpiry); + } + + /// + public async Task> GetActiveToolsForProviderAsync( + ProviderType providerType, + Func>> databaseFallback) + { + var cacheKey = CacheKeys.ProviderTool.ByProvider(providerType.ToString()); + + var result = await GetOrFallbackAsync>( + cacheKey, + ServiceName, + async () => await databaseFallback(providerType) as List, + debugLabel: $"Provider tools for {providerType}"); + + return result ?? new List(); + } + + /// + public async Task InvalidateProviderAsync(ProviderType providerType) + { + try + { + var cacheKey = CacheKeys.ProviderTool.ByProvider(providerType.ToString()); + await Database.KeyDeleteAsync(cacheKey); + await TrackInvalidationAsync(ServiceName); + Logger.LogDebug("Provider tool cache invalidated for {ProviderType}", providerType); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error invalidating provider tool cache for {ProviderType}", providerType); + } + } + + /// + public async Task ClearAllAsync() + { + await ClearAllByPatternAsync(CacheKeys.ProviderTool.Prefix + "*"); + Logger.LogWarning("All provider tool cache entries cleared"); + } + + /// + public async Task GetStatsAsync() + { + try + { + var (hits, misses, invalidations, resetTime) = await GetBaseStatsAsync(ServiceName); + + return new ProviderToolCacheStats + { + HitCount = hits + PendingHits, + MissCount = misses + PendingMisses, + InvalidationCount = invalidations + PendingInvalidations, + LastResetTime = resetTime, + EntryCount = CountEntries(CacheKeys.ProviderTool.Prefix + "*") + }; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting provider tool cache statistics"); + return new ProviderToolCacheStats { LastResetTime = DateTime.UtcNow }; + } + } + + #region Pub/Sub + + private void OnToolInvalidated(RedisChannel channel, RedisValue message) + { + _ = OnToolInvalidatedAsync(message); + } + + private async Task OnToolInvalidatedAsync(RedisValue message) + { + try + { + var providerTypeStr = message.ToString(); + if (providerTypeStr == "*") + { + await ClearAllAsync(); + } + else if (Enum.TryParse(providerTypeStr, true, out var providerType)) + { + await InvalidateProviderAsync(providerType); + Logger.LogDebug("Invalidated provider tool cache from pub/sub: {ProviderType}", providerType); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error handling provider tool cache invalidation from pub/sub: {Message}", + message.ToString()); + } + } + + #endregion +} diff --git a/Services/ConduitLLM.Gateway/Services/RedisVirtualKeyCache.cs b/Services/ConduitLLM.Gateway/Services/RedisVirtualKeyCache.cs index 5cd500526..7c489a097 100644 --- a/Services/ConduitLLM.Gateway/Services/RedisVirtualKeyCache.cs +++ b/Services/ConduitLLM.Gateway/Services/RedisVirtualKeyCache.cs @@ -1,103 +1,105 @@ +using System.Diagnostics; using System.Text.Json; using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; +using ConduitLLM.Gateway.Metrics; namespace ConduitLLM.Gateway.Services { /// /// Redis-based Virtual Key cache with immediate invalidation for security-critical validation /// - public class RedisVirtualKeyCache : ConduitLLM.Core.Interfaces.IVirtualKeyCache, IBatchInvalidatable + public class RedisVirtualKeyCache : RedisCacheServiceBase, ConduitLLM.Core.Interfaces.IVirtualKeyCache, IBatchInvalidatable { - private readonly IDatabase _database; private readonly ISubscriber _subscriber; - private readonly ILogger _logger; - private readonly TimeSpan _defaultExpiry = TimeSpan.FromMinutes(30); // Fallback expiry - private const string KeyPrefix = "vkey:"; - private const string InvalidationChannel = "vkey_invalidated"; - private const string BatchInvalidationChannel = "vkey_batch_invalidated"; - - // Statistics tracking keys - private const string STATS_HIT_KEY = "conduit:cache:stats:hits"; - private const string STATS_MISS_KEY = "conduit:cache:stats:misses"; - private const string STATS_INVALIDATION_KEY = "conduit:cache:stats:invalidations"; - private const string STATS_RESET_TIME_KEY = "conduit:cache:stats:reset_time"; public RedisVirtualKeyCache( IConnectionMultiplexer redis, ILogger logger) + : base(redis, logger, TimeSpan.FromMinutes(30)) { - _database = redis.GetDatabase(); _subscriber = redis.GetSubscriber(); - _logger = logger; // Subscribe to invalidation messages - _subscriber.Subscribe(RedisChannel.Literal(InvalidationChannel), OnKeyInvalidated); - _subscriber.Subscribe(RedisChannel.Literal(BatchInvalidationChannel), OnBatchInvalidated); + _subscriber.Subscribe(RedisChannel.Literal(CacheKeys.VirtualKey.InvalidationChannel), OnKeyInvalidated); + _subscriber.Subscribe(RedisChannel.Literal(CacheKeys.VirtualKey.BatchInvalidationChannel), OnBatchInvalidated); } + #region Legacy Stats Override โ€” VirtualKeyCache uses different key names + + protected override Task TrackHitAsync(string serviceName) + => Database.StringIncrementAsync(CacheKeys.Stats.VirtualKeyHits); + + protected override Task TrackMissAsync(string serviceName) + => Database.StringIncrementAsync(CacheKeys.Stats.VirtualKeyMisses); + + protected override Task TrackInvalidationAsync(string serviceName, long count = 1) + => Database.StringIncrementAsync(CacheKeys.Stats.VirtualKeyInvalidations, count); + + #endregion + /// /// Get Virtual Key from cache with immediate fallback to database if not found /// - /// Hashed key value - /// Function to get from database if cache miss - /// Virtual Key if found and valid, null otherwise public async Task GetVirtualKeyAsync( - string keyHash, + string keyHash, Func> databaseFallback) { - var cacheKey = KeyPrefix + keyHash; - + var cacheKey = CacheKeys.VirtualKey.ByHash(keyHash); + var sw = Stopwatch.StartNew(); + try { // Try Redis first - this is ~50x faster than database - var cachedValue = await _database.StringGetAsync(cacheKey); - + var cachedValue = await Database.StringGetAsync(cacheKey); + if (cachedValue.HasValue) { var jsonString = (string?)cachedValue; if (jsonString is not null) { var virtualKey = JsonSerializer.Deserialize(jsonString); - + // Validate key is still enabled and not expired if (virtualKey != null && IsKeyValid(virtualKey)) { - _logger.LogDebug("Virtual Key cache hit: {KeyHash}", keyHash); - // Increment hit counter - await _database.StringIncrementAsync(STATS_HIT_KEY); + Logger.LogDebug("Virtual Key cache hit: {KeyHash}", keyHash); + await TrackHitAsync(CacheKeys.Stats.VirtualKeyService); + GatewayCacheMetrics.RecordHit("virtualkey"); + GatewayCacheMetrics.RecordLatency("virtualkey", "get", sw.Elapsed.TotalSeconds); return virtualKey; } else { // Invalid key in cache, remove it - await _database.KeyDeleteAsync(cacheKey); - _logger.LogDebug("Removed invalid Virtual Key from cache: {KeyHash}", keyHash); + await Database.KeyDeleteAsync(cacheKey); + Logger.LogDebug("Removed invalid Virtual Key from cache: {KeyHash}", keyHash); } } } - + // Cache miss or invalid key - fallback to database - _logger.LogDebug("Virtual Key cache miss, querying database: {KeyHash}", keyHash); - // Increment miss counter - await _database.StringIncrementAsync(STATS_MISS_KEY); + Logger.LogDebug("Virtual Key cache miss, querying database: {KeyHash}", keyHash); + await TrackMissAsync(CacheKeys.Stats.VirtualKeyService); + GatewayCacheMetrics.RecordMiss("virtualkey"); var dbKey = await databaseFallback(keyHash); - + if (dbKey != null && IsKeyValid(dbKey)) { - // Cache the valid key await SetVirtualKeyAsync(keyHash, dbKey); return dbKey; } - + + GatewayCacheMetrics.RecordLatency("virtualkey", "get", sw.Elapsed.TotalSeconds); return null; } catch (Exception ex) { - _logger.LogError(ex, "Error accessing Virtual Key cache, falling back to database: {KeyHash}", keyHash); - - // On any Redis error, fallback to database + Logger.LogError(ex, "Error accessing Virtual Key cache, falling back to database: {KeyHash}", keyHash); + GatewayCacheMetrics.RecordError("virtualkey", "get"); return await databaseFallback(keyHash); } } @@ -105,25 +107,23 @@ public RedisVirtualKeyCache( /// /// Cache a Virtual Key with automatic expiry /// - /// Hashed key value - /// Virtual Key to cache public async Task SetVirtualKeyAsync(string keyHash, VirtualKey virtualKey) { - var cacheKey = KeyPrefix + keyHash; - + var cacheKey = CacheKeys.VirtualKey.ByHash(keyHash); + try { var json = JsonSerializer.Serialize(virtualKey); var expiry = CalculateExpiry(virtualKey); - - await _database.StringSetAsync(cacheKey, json, expiry); - - _logger.LogDebug("Cached Virtual Key: {KeyHash}, expires in {ExpiryMinutes} minutes", + + await Database.StringSetAsync(cacheKey, json, expiry); + + Logger.LogDebug("Cached Virtual Key: {KeyHash}, expires in {ExpiryMinutes} minutes", keyHash, expiry.TotalMinutes); } catch (Exception ex) { - _logger.LogError(ex, "Error caching Virtual Key: {KeyHash}", keyHash); + Logger.LogError(ex, "Error caching Virtual Key: {KeyHash}", keyHash); // Don't throw - caching is optimization, not critical path } } @@ -132,27 +132,23 @@ public async Task SetVirtualKeyAsync(string keyHash, VirtualKey virtualKey) /// Immediately invalidate a Virtual Key across all instances /// CRITICAL: Call this when keys are disabled, deleted, or quota exceeded /// - /// Hashed key value to invalidate public async Task InvalidateVirtualKeyAsync(string keyHash) { - var cacheKey = KeyPrefix + keyHash; - + var cacheKey = CacheKeys.VirtualKey.ByHash(keyHash); + try { - // Remove from local Redis - await _database.KeyDeleteAsync(cacheKey); - - // Notify ALL instances to invalidate their caches - await _subscriber.PublishAsync(RedisChannel.Literal(InvalidationChannel), keyHash); - - // Increment invalidation counter - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY); - - _logger.LogInformation("Invalidated Virtual Key across all instances: {KeyHash}", keyHash); + await Database.KeyDeleteAsync(cacheKey); + await _subscriber.PublishAsync(RedisChannel.Literal(CacheKeys.VirtualKey.InvalidationChannel), keyHash); + await TrackInvalidationAsync(CacheKeys.Stats.VirtualKeyService); + GatewayCacheMetrics.RecordInvalidation("virtualkey", "explicit"); + + Logger.LogInformation("Invalidated Virtual Key across all instances: {KeyHash}", keyHash); } catch (Exception ex) { - _logger.LogError(ex, "Error invalidating Virtual Key: {KeyHash}", keyHash); + Logger.LogError(ex, "Error invalidating Virtual Key: {KeyHash}", keyHash); + GatewayCacheMetrics.RecordError("virtualkey", "invalidate"); throw; // This is critical for security - must not fail silently } } @@ -160,7 +156,6 @@ public async Task InvalidateVirtualKeyAsync(string keyHash) /// /// Bulk invalidate multiple keys (useful for quota updates) /// - /// Array of key hashes to invalidate public async Task InvalidateVirtualKeysAsync(string[] keyHashes) { try @@ -170,14 +165,14 @@ public async Task InvalidateVirtualKeysAsync(string[] keyHashes) { tasks[i] = InvalidateVirtualKeyAsync(keyHashes[i]); } - + await Task.WhenAll(tasks); - - _logger.LogInformation("Bulk invalidated {Count} Virtual Keys", keyHashes.Length); + + Logger.LogInformation("Bulk invalidated {Count} Virtual Keys", keyHashes.Length); } catch (Exception ex) { - _logger.LogError(ex, "Error in bulk Virtual Key invalidation"); + Logger.LogError(ex, "Error in bulk Virtual Key invalidation"); throw; } } @@ -189,115 +184,128 @@ public async Task InvalidateVirtualKeysAsync(string[] keyHashes) { try { - // Get the actual statistics from Redis - var hitCountTask = _database.StringGetAsync(STATS_HIT_KEY); - var missCountTask = _database.StringGetAsync(STATS_MISS_KEY); - var invalidationCountTask = _database.StringGetAsync(STATS_INVALIDATION_KEY); - var resetTimeTask = _database.StringGetAsync(STATS_RESET_TIME_KEY); - + var hitCountTask = Database.StringGetAsync(CacheKeys.Stats.VirtualKeyHits); + var missCountTask = Database.StringGetAsync(CacheKeys.Stats.VirtualKeyMisses); + var invalidationCountTask = Database.StringGetAsync(CacheKeys.Stats.VirtualKeyInvalidations); + var resetTimeTask = Database.StringGetAsync(CacheKeys.Stats.VirtualKeyResetTime); + await Task.WhenAll(hitCountTask, missCountTask, invalidationCountTask, resetTimeTask); - - // Parse values with defaults for missing keys - long hitCount = hitCountTask.Result.HasValue ? (long)hitCountTask.Result : 0; - long missCount = missCountTask.Result.HasValue ? (long)missCountTask.Result : 0; - long invalidationCount = invalidationCountTask.Result.HasValue ? (long)invalidationCountTask.Result : 0; - + + var hitCountValue = await hitCountTask; + var missCountValue = await missCountTask; + var invalidationCountValue = await invalidationCountTask; + var resetTimeValue = await resetTimeTask; + + long hitCount = hitCountValue.HasValue ? (long)hitCountValue : 0; + long missCount = missCountValue.HasValue ? (long)missCountValue : 0; + long invalidationCount = invalidationCountValue.HasValue ? (long)invalidationCountValue : 0; + DateTime lastResetTime = DateTime.UtcNow; - if (resetTimeTask.Result.HasValue) + if (resetTimeValue.HasValue) { - if (long.TryParse(resetTimeTask.Result.ToString(), out var ticks)) + if (long.TryParse(resetTimeValue.ToString(), out var ticks)) { lastResetTime = new DateTime(ticks, DateTimeKind.Utc); } } else { - // If no reset time exists, set it now - await _database.StringSetAsync(STATS_RESET_TIME_KEY, DateTime.UtcNow.Ticks.ToString()); + await Database.StringSetAsync(CacheKeys.Stats.VirtualKeyResetTime, DateTime.UtcNow.Ticks.ToString()); } - + return new ConduitLLM.Core.Interfaces.VirtualKeyCacheStats { HitCount = hitCount, MissCount = missCount, InvalidationCount = invalidationCount, - AverageGetTime = TimeSpan.Zero, // Not tracked in this implementation + AverageGetTime = TimeSpan.Zero, LastResetTime = lastResetTime }; } catch (Exception ex) { - _logger.LogError(ex, "Error getting cache statistics"); + Logger.LogError(ex, "Error getting cache statistics"); return new ConduitLLM.Core.Interfaces.VirtualKeyCacheStats(); } } - /// - /// Handle invalidation messages from other instances - /// - private async void OnKeyInvalidated(RedisChannel channel, RedisValue keyHash) + #region Pub/Sub Handlers + + private void OnKeyInvalidated(RedisChannel channel, RedisValue keyHash) + { + _ = OnKeyInvalidatedAsync(keyHash); + } + + private async Task OnKeyInvalidatedAsync(RedisValue keyHash) { try { - var cacheKey = KeyPrefix + keyHash; - await _database.KeyDeleteAsync(cacheKey); - - _logger.LogDebug("Invalidated Virtual Key from pub/sub: {KeyHash}", keyHash.ToString()); + var cacheKey = CacheKeys.VirtualKey.ByHash(keyHash.ToString()); + await Database.KeyDeleteAsync(cacheKey); + + Logger.LogDebug("Invalidated Virtual Key from pub/sub: {KeyHash}", keyHash.ToString()); } catch (Exception ex) { - _logger.LogError(ex, "Error handling key invalidation: {KeyHash}", keyHash.ToString()); + Logger.LogError(ex, "Error handling key invalidation: {KeyHash}", keyHash.ToString()); } } - /// - /// Validate that a Virtual Key is still usable - /// - private static bool IsKeyValid(VirtualKey key) + private void OnBatchInvalidated(RedisChannel channel, RedisValue message) { - // Note: Group balance validation happens at the service layer - // The cache only validates basic key properties - return key.IsEnabled && - (key.ExpiresAt == null || key.ExpiresAt > DateTime.UtcNow); + _ = OnBatchInvalidatedAsync(message); } - /// - /// Calculate appropriate cache expiry based on key properties - /// - private TimeSpan CalculateExpiry(VirtualKey key) + private async Task OnBatchInvalidatedAsync(RedisValue message) { - // If key expires soon, don't cache for long - if (key.ExpiresAt.HasValue) + try { - var timeUntilExpiry = key.ExpiresAt.Value - DateTime.UtcNow; - if (timeUntilExpiry < _defaultExpiry) + var batchMessage = JsonSerializer.Deserialize(message!.ToString()); + if (batchMessage?.KeyHashes != null) { - return timeUntilExpiry; + var batch = Database.CreateBatch(); + var deleteTasks = new List>(); + + foreach (var keyHash in batchMessage.KeyHashes) + { + var cacheKey = CacheKeys.VirtualKey.ByHash(keyHash); + deleteTasks.Add(batch.KeyDeleteAsync(cacheKey)); + } + + batch.Execute(); + await Task.WhenAll(deleteTasks); + + Logger.LogDebug( + "Batch invalidated {Count} virtual keys from pub/sub", + batchMessage.KeyHashes.Length); } } - - // Note: Budget tracking is now at the group level, so we can't check it here - // The service layer will invalidate keys when group balance is depleted - - return _defaultExpiry; + catch (Exception ex) + { + Logger.LogError(ex, "Error handling batch key invalidation"); + } } + #endregion + + #region Batch Invalidation + /// /// Batch invalidate multiple virtual keys for optimal performance /// public async Task InvalidateBatchAsync( - IEnumerable requests, + IEnumerable requests, CancellationToken cancellationToken = default) { var keyHashes = requests .Where(r => r.EntityType == CacheType.VirtualKey.ToString()) - .Select(r => KeyPrefix + r.EntityId) + .Select(r => CacheKeys.VirtualKey.ByHash(r.EntityId)) .ToArray(); - + if (keyHashes.Length == 0) { - return new BatchInvalidationResult - { + return new BatchInvalidationResult + { Success = true, ProcessedCount = 0, Duration = TimeSpan.Zero @@ -305,45 +313,40 @@ public async Task InvalidateBatchAsync( } var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - + try { - // Use Redis pipeline for batch delete - var batch = _database.CreateBatch(); + var batch = Database.CreateBatch(); var deleteTasks = new List>(); - + foreach (var key in keyHashes) { deleteTasks.Add(batch.KeyDeleteAsync(key)); } - - // Execute batch + batch.Execute(); - - // Wait for all deletes to complete await Task.WhenAll(deleteTasks); - - // Update invalidation statistics - await _database.StringIncrementAsync(STATS_INVALIDATION_KEY, keyHashes.Length); - + + await TrackInvalidationAsync(CacheKeys.Stats.VirtualKeyService, keyHashes.Length); + // Publish batch invalidation message to other instances var batchMessage = new VirtualKeyBatchInvalidation { - KeyHashes = keyHashes.Select(k => k.Replace(KeyPrefix, "")).ToArray(), + KeyHashes = keyHashes.Select(k => k.Replace(CacheKeys.VirtualKey.Prefix, "")).ToArray(), Timestamp = DateTime.UtcNow }; - + await _subscriber.PublishAsync( - RedisChannel.Literal(BatchInvalidationChannel), + RedisChannel.Literal(CacheKeys.VirtualKey.BatchInvalidationChannel), JsonSerializer.Serialize(batchMessage)); - + stopwatch.Stop(); - - _logger.LogInformation( + + Logger.LogInformation( "Batch invalidated {Count} virtual keys in {Duration}ms", - keyHashes.Length, + keyHashes.Length, stopwatch.ElapsedMilliseconds); - + return new BatchInvalidationResult { Success = true, @@ -354,8 +357,8 @@ await _subscriber.PublishAsync( catch (Exception ex) { stopwatch.Stop(); - _logger.LogError(ex, "Failed to batch invalidate virtual keys"); - + Logger.LogError(ex, "Failed to batch invalidate virtual keys"); + return new BatchInvalidationResult { Success = false, @@ -366,46 +369,36 @@ await _subscriber.PublishAsync( } } - /// - /// Handle batch invalidation messages from other instances - /// - private async void OnBatchInvalidated(RedisChannel channel, RedisValue message) + #endregion + + #region Helpers + + private static bool IsKeyValid(VirtualKey key) { - try + return key.IsEnabled && + (key.ExpiresAt == null || key.ExpiresAt > DateTime.UtcNow); + } + + private TimeSpan CalculateExpiry(VirtualKey key) + { + if (key.ExpiresAt.HasValue) { - var batchMessage = JsonSerializer.Deserialize(message!.ToString()); - if (batchMessage?.KeyHashes != null) + var timeUntilExpiry = key.ExpiresAt.Value - DateTime.UtcNow; + if (timeUntilExpiry < DefaultExpiry) { - var batch = _database.CreateBatch(); - var deleteTasks = new List>(); - - foreach (var keyHash in batchMessage.KeyHashes) - { - var cacheKey = KeyPrefix + keyHash; - deleteTasks.Add(batch.KeyDeleteAsync(cacheKey)); - } - - batch.Execute(); - await Task.WhenAll(deleteTasks); - - _logger.LogDebug( - "Batch invalidated {Count} virtual keys from pub/sub", - batchMessage.KeyHashes.Length); + return timeUntilExpiry; } } - catch (Exception ex) - { - _logger.LogError(ex, "Error handling batch key invalidation"); - } + + return DefaultExpiry; } - /// - /// Message for batch invalidation pub/sub - /// + #endregion + private class VirtualKeyBatchInvalidation { public string[] KeyHashes { get; set; } = Array.Empty(); public DateTime Timestamp { get; set; } } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/SecurityService.Authentication.cs b/Services/ConduitLLM.Gateway/Services/SecurityService.Authentication.cs deleted file mode 100644 index 30b347e50..000000000 --- a/Services/ConduitLLM.Gateway/Services/SecurityService.Authentication.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Text.Json; - -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; - -namespace ConduitLLM.Gateway.Services -{ - public partial class SecurityService - { - /// - public async Task RecordFailedAuthAsync(string ipAddress, string attemptedKey) - { - var key = $"{FAILED_LOGIN_PREFIX}{ipAddress}"; - var banKey = $"{BAN_PREFIX}{ipAddress}"; - - // Get current failed attempts - var attempts = 0; - if (_options.UseDistributedTracking && _distributedCache != null) - { - var cachedValue = await _distributedCache.GetStringAsync(key); - if (!string.IsNullOrEmpty(cachedValue)) - { - var data = JsonSerializer.Deserialize(cachedValue); - attempts = data?.Attempts ?? 0; - } - } - else - { - attempts = _memoryCache.Get(key); - } - - attempts++; - - // Log the attempt - _logger.LogWarning("Failed authentication attempt {Attempts}/{MaxAttempts} for IP {IpAddress} with key {Key}", - attempts, _options.FailedAuth.MaxAttempts, ipAddress, - attemptedKey.Length > 10 ? attemptedKey.Substring(0, 10) + "..." : attemptedKey); - - // Check if we should ban the IP - if (attempts >= _options.FailedAuth.MaxAttempts) - { - var banInfo = new BannedIpInfo - { - BannedUntil = DateTime.UtcNow.AddMinutes(_options.FailedAuth.BanDurationMinutes), - FailedAttempts = attempts, - Source = SERVICE_NAME, - Reason = "Exceeded max failed Virtual Key authentication attempts", - LastAttemptedKey = attemptedKey.Length > 10 ? attemptedKey.Substring(0, 10) + "..." : attemptedKey - }; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.SetStringAsync( - banKey, - JsonSerializer.Serialize(banInfo), - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes) - }); - } - else - { - _memoryCache.Set(banKey, banInfo, TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes)); - } - - _logger.LogWarning("IP {IpAddress} has been banned after {Attempts} failed Virtual Key authentication attempts", - ipAddress, attempts); - - // Record IP ban in security event monitoring - _securityEventMonitoring?.RecordIpBan(ipAddress, "Exceeded max failed Virtual Key authentication attempts", attempts); - - // Clear the failed attempts counter - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.RemoveAsync(key); - } - else - { - _memoryCache.Remove(key); - } - } - else - { - // Update the failed attempts counter - var authData = new FailedAuthData - { - Attempts = attempts, - Source = SERVICE_NAME, - LastAttempt = DateTime.UtcNow, - LastAttemptedKey = attemptedKey.Length > 10 ? attemptedKey.Substring(0, 10) + "..." : attemptedKey - }; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.SetStringAsync( - key, - JsonSerializer.Serialize(authData), - new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes) - }); - } - else - { - _memoryCache.Set(key, attempts, TimeSpan.FromMinutes(_options.FailedAuth.BanDurationMinutes)); - } - } - } - - /// - public async Task ClearFailedAuthAttemptsAsync(string ipAddress) - { - var key = $"{FAILED_LOGIN_PREFIX}{ipAddress}"; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.RemoveAsync(key); - } - else - { - _memoryCache.Remove(key); - } - - _logger.LogDebug("Cleared failed authentication attempts for IP {IpAddress} after successful auth", ipAddress); - } - - /// - public async Task IsIpBannedAsync(string ipAddress) - { - var banKey = $"{BAN_PREFIX}{ipAddress}"; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - var cachedValue = await _distributedCache.GetStringAsync(banKey); - if (!string.IsNullOrEmpty(cachedValue)) - { - var banInfo = JsonSerializer.Deserialize(cachedValue); - return banInfo?.BannedUntil > DateTime.UtcNow; - } - } - else - { - var banInfo = _memoryCache.Get(banKey); - return banInfo?.BannedUntil > DateTime.UtcNow; - } - - return false; - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/SecurityService.Core.cs b/Services/ConduitLLM.Gateway/Services/SecurityService.Core.cs index 0efc43836..444ab9703 100644 --- a/Services/ConduitLLM.Gateway/Services/SecurityService.Core.cs +++ b/Services/ConduitLLM.Gateway/Services/SecurityService.Core.cs @@ -1,138 +1,60 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using ConduitLLM.Gateway.Options; using ConduitLLM.Configuration.Entities; using ConduitLLM.Security.Interfaces; +using ConduitLLM.Security.Models; +using ConduitLLM.Security.Options; +using ConduitLLM.Security.Services; namespace ConduitLLM.Gateway.Services { /// - /// Unified security service for Gateway API + /// Gateway-specific security service interface. + /// Virtual Key rate limiting is enforced separately by VirtualKeyRateLimitMiddleware. /// - public interface ISecurityService + public interface IGatewaySecurityService : ConduitLLM.Security.Interfaces.ISecurityService { - /// - /// Checks if a request is allowed based on all security rules - /// - Task IsRequestAllowedAsync(HttpContext context); - - /// - /// Records a failed authentication attempt for an IP - /// - Task RecordFailedAuthAsync(string ipAddress, string attemptedKey); - - /// - /// Clears failed authentication attempts for an IP - /// - Task ClearFailedAuthAttemptsAsync(string ipAddress); - - /// - /// Checks if an IP is banned due to failed authentication - /// - Task IsIpBannedAsync(string ipAddress); - - /// - /// Checks Virtual Key rate limits - /// - Task CheckVirtualKeyRateLimitAsync(HttpContext context, string virtualKeyId, string endpoint); } /// - /// Result of a security check + /// Implementation of security service for Gateway API. + /// Handles authentication-related state (failed-auth tracking, IP bans, IP filtering, + /// discovery-specific rate limits, security event monitoring). Virtual Key rate limits + /// are enforced by . /// - public class SecurityCheckResult + public partial class SecurityService : SecurityServiceBase, IGatewaySecurityService { - /// - /// Whether the request is allowed - /// - public bool IsAllowed { get; set; } - - /// - /// Reason for denial if not allowed - /// - public string Reason { get; set; } = ""; - - /// - /// HTTP status code to return - /// - public int? StatusCode { get; set; } - - /// - /// Additional headers to include in response - /// - public Dictionary Headers { get; set; } = new(); - } - - /// - /// Result of a rate limit check - /// - public class RateLimitCheckResult - { - /// - /// Whether the request is allowed - /// - public bool IsAllowed { get; set; } - - /// - /// Requests remaining in current window - /// - public int? Remaining { get; set; } - - /// - /// Total limit for the window - /// - public int? Limit { get; set; } - - /// - /// Window reset time - /// - public DateTime? ResetsAt { get; set; } - } - - /// - /// Implementation of unified security service for Gateway API - /// - public partial class SecurityService : ISecurityService - { - private readonly SecurityOptions _options; + private readonly GatewaySecurityOptions _options; private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly IMemoryCache _memoryCache; - private readonly IDistributedCache? _distributedCache; private readonly IServiceProvider _serviceProvider; private readonly ISecurityEventMonitoringService? _securityEventMonitoring; - // Cache keys - same as WebAdmin/Admin for shared tracking - private const string RATE_LIMIT_PREFIX = "rate_limit:"; - private const string FAILED_LOGIN_PREFIX = "failed_login:"; - private const string BAN_PREFIX = "ban:"; - private const string VKEY_RATE_LIMIT_PREFIX = "vkey_rate:"; + /// + protected override string ServiceName => "core-api"; - // Service identifier for tracking - private const string SERVICE_NAME = "core-api"; + /// + protected override SecurityOptionsBase Options => _options; /// - /// Initializes a new instance of the SecurityService + /// Initializes a new instance of the Gateway SecurityService /// public SecurityService( - IOptions options, + IOptions options, IConfiguration configuration, ILogger logger, IMemoryCache memoryCache, IServiceProvider serviceProvider) + : base(logger, memoryCache, serviceProvider.GetService()) { _options = options.Value; _configuration = configuration; - _logger = logger; - _memoryCache = memoryCache; - _distributedCache = serviceProvider.GetService(); _serviceProvider = serviceProvider; _securityEventMonitoring = serviceProvider.GetService(); } /// - public async Task IsRequestAllowedAsync(HttpContext context) + public override async Task IsRequestAllowedAsync(HttpContext context) { var clientIp = GetClientIpAddress(context); var path = context.Request.Path.Value ?? ""; @@ -140,52 +62,42 @@ public async Task IsRequestAllowedAsync(HttpContext context // Skip security checks for excluded paths if (IsPathExcluded(path, new List { "/health", "/metrics" })) { - return new SecurityCheckResult { IsAllowed = true }; + return SecurityCheckResult.Allowed(); } - // Check if authentication failed (set by VirtualKeyAuthenticationMiddleware) + // Check if authentication failed (set by VirtualKeyAuthenticationHandler) if (context.Items.ContainsKey("FailedAuth") && context.Items["FailedAuth"] is bool failedAuth && failedAuth) { - // Record the failed attempt var attemptedKey = context.Items["AttemptedKey"] as string ?? "unknown"; await RecordFailedAuthAsync(clientIp, attemptedKey); - - // Record in security event monitoring _securityEventMonitoring?.RecordAuthenticationFailure(clientIp, attemptedKey, path); } - // Check if IP is banned due to failed authentication + // Check if IP is banned if (await IsIpBannedAsync(clientIp)) { - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "IP is banned due to excessive failed authentication attempts", - StatusCode = 403 - }; + return SecurityCheckResult.Denied("IP is banned due to excessive failed authentication attempts"); } - // If authentication succeeded, clear failed attempts for this IP + // If authentication succeeded, clear failed attempts if (context.Items.ContainsKey("AuthSuccess") && context.Items["AuthSuccess"] is bool authSuccess && authSuccess) { await ClearFailedAuthAttemptsAsync(clientIp); - - // Record successful authentication var virtualKey = context.Items["VirtualKey"] as string ?? ""; _securityEventMonitoring?.RecordAuthenticationSuccess(clientIp, virtualKey, path); } - // Check IP-based rate limiting (if enabled) + // Check IP-based rate limiting if (_options.RateLimiting.Enabled && !IsPathExcluded(path, _options.RateLimiting.ExcludedPaths)) { - var rateLimitResult = await CheckIpRateLimitAsync(clientIp, path); + var rateLimitResult = await CheckIpRateLimitWithDiscoveryAsync(clientIp, path); if (!rateLimitResult.IsAllowed) { return rateLimitResult; } } - // Check IP filtering (if enabled) + // Check IP filtering if (_options.IpFiltering.Enabled && !IsPathExcluded(path, _options.IpFiltering.ExcludedPaths)) { var ipFilterResult = await CheckIpFilterAsync(clientIp); @@ -195,32 +107,33 @@ public async Task IsRequestAllowedAsync(HttpContext context } } - // Check Virtual Key rate limits (if authenticated and enabled) - if (_options.VirtualKey.EnforceRateLimits && context.Items.ContainsKey("VirtualKeyEntity")) + // Note: Virtual Key rate limits (RPM/RPD) are enforced by + // VirtualKeyRateLimitMiddleware, which runs after authentication and uses + // the Redis-backed sliding-window IVirtualKeyRateLimitService. + + return SecurityCheckResult.Allowed(); + } + + /// + protected override void OnIpBanned(string ipAddress, BannedIpInfo banInfo, int attempts) + { + _securityEventMonitoring?.RecordIpBan(ipAddress, banInfo.Reason, attempts); + } + + /// + protected override async Task CheckDatabaseIpFilterAsync(string ipAddress) + { + using var scope = _serviceProvider.CreateScope(); + var ipFilterService = scope.ServiceProvider.GetRequiredService(); + var isAllowedByDb = await ipFilterService.IsIpAllowedAsync(ipAddress); + + if (!isAllowedByDb) { - var virtualKey = context.Items["VirtualKeyEntity"] as VirtualKey; - if (virtualKey != null && (virtualKey.RateLimitRpm.HasValue || virtualKey.RateLimitRpd.HasValue)) - { - var vkeyRateLimitResult = await CheckVirtualKeyRateLimitAsync(context, virtualKey.Id.ToString(), path); - if (!vkeyRateLimitResult.IsAllowed) - { - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "Virtual Key rate limit exceeded", - StatusCode = 429, - Headers = new Dictionary - { - ["X-RateLimit-Limit"] = vkeyRateLimitResult.Limit?.ToString() ?? "0", - ["X-RateLimit-Remaining"] = vkeyRateLimitResult.Remaining?.ToString() ?? "0", - ["X-RateLimit-Reset"] = vkeyRateLimitResult.ResetsAt?.ToUnixTimeSeconds().ToString() ?? "" - } - }; - } - } + Logger.LogWarning("IP {IpAddress} blocked by database IP filter", ipAddress); + return SecurityCheckResult.Denied("IP address not allowed"); } - return new SecurityCheckResult { IsAllowed = true }; + return SecurityCheckResult.Allowed(); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/SecurityService.Helpers.cs b/Services/ConduitLLM.Gateway/Services/SecurityService.Helpers.cs index 975cf9e5a..3735ecf30 100644 --- a/Services/ConduitLLM.Gateway/Services/SecurityService.Helpers.cs +++ b/Services/ConduitLLM.Gateway/Services/SecurityService.Helpers.cs @@ -1,34 +1,8 @@ namespace ConduitLLM.Gateway.Services { - public partial class SecurityService - { - // Data structures for Redis storage (compatible with WebAdmin/Admin) - private class FailedAuthData - { - public int Attempts { get; set; } - public string Source { get; set; } = ""; - public DateTime LastAttempt { get; set; } - public string LastAttemptedKey { get; set; } = ""; - } - - private class BannedIpInfo - { - public DateTime BannedUntil { get; set; } - public int FailedAttempts { get; set; } - public string Source { get; set; } = ""; - public string Reason { get; set; } = ""; - public string LastAttemptedKey { get; set; } = ""; - } - - private class RateLimitData - { - public int Count { get; set; } - public string Source { get; set; } = ""; - public DateTime WindowStart { get; set; } - } - } - - // Extension method for DateTime to Unix timestamp + /// + /// Extension method for DateTime to Unix timestamp + /// internal static class DateTimeExtensions { public static long ToUnixTimeSeconds(this DateTime dateTime) @@ -36,4 +10,4 @@ public static long ToUnixTimeSeconds(this DateTime dateTime) return ((DateTimeOffset)dateTime).ToUnixTimeSeconds(); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/SecurityService.IpFiltering.cs b/Services/ConduitLLM.Gateway/Services/SecurityService.IpFiltering.cs deleted file mode 100644 index 8f64d27c9..000000000 --- a/Services/ConduitLLM.Gateway/Services/SecurityService.IpFiltering.cs +++ /dev/null @@ -1,69 +0,0 @@ -using ConduitLLM.Core.Utilities; -using ConduitLLM.Gateway.Interfaces; - -namespace ConduitLLM.Gateway.Services -{ - public partial class SecurityService - { - private async Task CheckIpFilterAsync(string ipAddress) - { - // Check if it's a private IP and we allow private IPs - if (_options.IpFiltering.AllowPrivateIps) - { - if (IpAddressHelper.IsPrivateIp(ipAddress)) - { - _logger.LogDebug("Private/Intranet IP {IpAddress} is automatically allowed", ipAddress); - return new SecurityCheckResult { IsAllowed = true }; - } - } - - // Check environment variable based filters - var isInWhitelist = _options.IpFiltering.Whitelist.Any(rule => IpAddressHelper.IsIpInRange(ipAddress, rule)); - var isInBlacklist = _options.IpFiltering.Blacklist.Any(rule => IpAddressHelper.IsIpInRange(ipAddress, rule)); - - var isAllowed = _options.IpFiltering.Mode.ToLower() == "restrictive" - ? isInWhitelist && !isInBlacklist - : !isInBlacklist; - - if (!isAllowed) - { - _logger.LogWarning("IP {IpAddress} blocked by IP filter rules", ipAddress); - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "IP address not allowed", - StatusCode = 403 - }; - } - - // Also check database-based IP filters - using (var scope = _serviceProvider.CreateScope()) - { - var ipFilterService = scope.ServiceProvider.GetRequiredService(); - var isAllowedByDb = await ipFilterService.IsIpAllowedAsync(ipAddress); - if (!isAllowedByDb) - { - _logger.LogWarning("IP {IpAddress} blocked by database IP filter", ipAddress); - return new SecurityCheckResult - { - IsAllowed = false, - Reason = "IP address not allowed", - StatusCode = 403 - }; - } - } - - return new SecurityCheckResult { IsAllowed = true }; - } - - private bool IsPathExcluded(string path, List excludedPaths) - { - return excludedPaths.Any(excluded => path.StartsWith(excluded, StringComparison.OrdinalIgnoreCase)); - } - - private string GetClientIpAddress(HttpContext context) - { - return IpAddressHelper.GetClientIpAddress(context); - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/SecurityService.RateLimiting.cs b/Services/ConduitLLM.Gateway/Services/SecurityService.RateLimiting.cs index 5fb3edaa1..0abcb34da 100644 --- a/Services/ConduitLLM.Gateway/Services/SecurityService.RateLimiting.cs +++ b/Services/ConduitLLM.Gateway/Services/SecurityService.RateLimiting.cs @@ -1,98 +1,27 @@ using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; -using ConduitLLM.Configuration.Entities; +using ConduitLLM.Security.Models; namespace ConduitLLM.Gateway.Services { + /// + /// IP-based rate limiting with discovery-specific overrides for the Gateway. + /// Virtual Key (RPM/RPD) rate limiting is enforced separately by + /// . + /// public partial class SecurityService { - /// - public async Task CheckVirtualKeyRateLimitAsync(HttpContext context, string virtualKeyId, string endpoint) - { - // Get the Virtual Key entity from context to check its limits - if (!context.Items.ContainsKey("VirtualKeyEntity")) - { - return new RateLimitCheckResult { IsAllowed = true }; - } - - var virtualKey = context.Items["VirtualKeyEntity"] as VirtualKey; - if (virtualKey == null) - { - return new RateLimitCheckResult { IsAllowed = true }; - } - - var now = DateTime.UtcNow; - var result = new RateLimitCheckResult { IsAllowed = true }; - - // Check RPM (Requests Per Minute) limit - if (virtualKey.RateLimitRpm.HasValue && virtualKey.RateLimitRpm.Value > 0) - { - var rpmKey = $"{VKEY_RATE_LIMIT_PREFIX}rpm:{virtualKeyId}"; - var rpmCount = await GetRateLimitCountAsync(rpmKey, 60); // 60 seconds window - - if (rpmCount >= virtualKey.RateLimitRpm.Value) - { - _logger.LogWarning("Virtual Key {KeyId} exceeded RPM limit: {Count}/{Limit}", - virtualKeyId, rpmCount, virtualKey.RateLimitRpm.Value); - - result.IsAllowed = false; - result.Limit = virtualKey.RateLimitRpm.Value; - result.Remaining = 0; - result.ResetsAt = now.AddSeconds(60); - return result; - } - - // Increment counter - await IncrementRateLimitCountAsync(rpmKey, 60); - result.Limit = virtualKey.RateLimitRpm.Value; - result.Remaining = virtualKey.RateLimitRpm.Value - (rpmCount + 1); - } - - // Check RPD (Requests Per Day) limit - if (virtualKey.RateLimitRpd.HasValue && virtualKey.RateLimitRpd.Value > 0) - { - var rpdKey = $"{VKEY_RATE_LIMIT_PREFIX}rpd:{virtualKeyId}"; - var rpdCount = await GetRateLimitCountAsync(rpdKey, 86400); // 24 hours in seconds - - if (rpdCount >= virtualKey.RateLimitRpd.Value) - { - _logger.LogWarning("Virtual Key {KeyId} exceeded RPD limit: {Count}/{Limit}", - virtualKeyId, rpdCount, virtualKey.RateLimitRpd.Value); - - result.IsAllowed = false; - result.Limit = virtualKey.RateLimitRpd.Value; - result.Remaining = 0; - result.ResetsAt = now.Date.AddDays(1); // Next day - return result; - } - - // Increment counter - await IncrementRateLimitCountAsync(rpdKey, 86400); - - // If we have RPM limit, that takes precedence for response headers - if (!virtualKey.RateLimitRpm.HasValue) - { - result.Limit = virtualKey.RateLimitRpd.Value; - result.Remaining = virtualKey.RateLimitRpd.Value - (rpdCount + 1); - result.ResetsAt = now.Date.AddDays(1); - } - } - - return result; - } - private async Task GetRateLimitCountAsync(string key, int windowSeconds) { - if (_options.UseDistributedTracking && _distributedCache != null) + if (_options.UseDistributedTracking && DistributedCache != null) { - var cachedValue = await _distributedCache.GetStringAsync(key); + var cachedValue = await DistributedCache.GetStringAsync(key); if (!string.IsNullOrEmpty(cachedValue)) { if (int.TryParse(cachedValue, out var count)) return count; - - // Try to deserialize as complex object for backward compatibility + try { var data = JsonSerializer.Deserialize(cachedValue); @@ -106,9 +35,9 @@ private async Task GetRateLimitCountAsync(string key, int windowSeconds) } else { - return _memoryCache.Get(key); + return MemoryCache.Get(key); } - + return 0; } @@ -117,9 +46,9 @@ private async Task IncrementRateLimitCountAsync(string key, int windowSeconds) var currentCount = await GetRateLimitCountAsync(key, windowSeconds); currentCount++; - if (_options.UseDistributedTracking && _distributedCache != null) + if (_options.UseDistributedTracking && DistributedCache != null) { - await _distributedCache.SetStringAsync( + await DistributedCache.SetStringAsync( key, currentCount.ToString(), new DistributedCacheEntryOptions @@ -129,11 +58,14 @@ await _distributedCache.SetStringAsync( } else { - _memoryCache.Set(key, currentCount, TimeSpan.FromSeconds(windowSeconds)); + MemoryCache.Set(key, currentCount, TimeSpan.FromSeconds(windowSeconds)); } } - private async Task CheckIpRateLimitAsync(string ipAddress, string path = "") + /// + /// Checks IP rate limiting with discovery-specific overrides + /// + private async Task CheckIpRateLimitWithDiscoveryAsync(string ipAddress, string path) { // Check discovery-specific rate limiting first if (_options.RateLimiting.Discovery.Enabled && IsDiscoveryPath(path)) @@ -145,97 +77,26 @@ private async Task CheckIpRateLimitAsync(string ipAddress, } } - // Check general IP rate limiting - var key = $"{RATE_LIMIT_PREFIX}{SERVICE_NAME}:{ipAddress}"; - var now = DateTime.UtcNow; - - // Get current request count - var requestCount = 0; - if (_options.UseDistributedTracking && _distributedCache != null) - { - var cachedValue = await _distributedCache.GetStringAsync(key); - if (!string.IsNullOrEmpty(cachedValue)) - { - var data = JsonSerializer.Deserialize(cachedValue); - requestCount = data?.Count ?? 0; - } - } - else - { - requestCount = _memoryCache.Get(key); - } - - requestCount++; - - if (requestCount > _options.RateLimiting.MaxRequests) - { - _logger.LogWarning("IP rate limit exceeded for {IpAddress}: {Count} requests in {Window} seconds", - ipAddress, requestCount, _options.RateLimiting.WindowSeconds); - - return new SecurityCheckResult - { - IsAllowed = false, - Reason = $"Rate limit exceeded for path {path}", - StatusCode = 429, - Headers = new Dictionary - { - ["Retry-After"] = _options.RateLimiting.WindowSeconds.ToString(), - ["X-RateLimit-Limit"] = _options.RateLimiting.MaxRequests.ToString(), - ["X-RateLimit-Scope"] = "general" - } - }; - } - - // Update the counter - var rateLimitData = new RateLimitData - { - Count = requestCount, - Source = SERVICE_NAME, - WindowStart = now - }; - - if (_options.UseDistributedTracking && _distributedCache != null) - { - await _distributedCache.SetStringAsync( - key, - JsonSerializer.Serialize(rateLimitData), - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.RateLimiting.WindowSeconds) - }); - } - else - { - _memoryCache.Set(key, requestCount, TimeSpan.FromSeconds(_options.RateLimiting.WindowSeconds)); - } - - return new SecurityCheckResult { IsAllowed = true }; + // Fall through to base IP rate limiting + return await CheckIpRateLimitAsync(ipAddress); } - /// - /// Checks if the path is a discovery-related endpoint - /// private bool IsDiscoveryPath(string path) { return _options.RateLimiting.Discovery.DiscoveryPaths .Any(discoveryPath => path.Contains(discoveryPath, StringComparison.OrdinalIgnoreCase)); } - /// - /// Checks discovery-specific rate limits - /// private async Task CheckDiscoveryRateLimitAsync(string ipAddress, string path) { - var discoveryKey = $"{RATE_LIMIT_PREFIX}discovery:{ipAddress}"; - var now = DateTime.UtcNow; + var discoveryKey = $"{RateLimitPrefix}discovery:{ipAddress}"; - // Get current discovery request count var discoveryCount = await GetRateLimitCountAsync(discoveryKey, _options.RateLimiting.Discovery.WindowSeconds); discoveryCount++; if (discoveryCount > _options.RateLimiting.Discovery.MaxRequests) { - _logger.LogWarning("Discovery rate limit exceeded for {IpAddress}: {Count} requests in {Window} seconds for path {Path}", + Logger.LogWarning("Discovery rate limit exceeded for {IpAddress}: {Count} requests in {Window} seconds for path {Path}", ipAddress, discoveryCount, _options.RateLimiting.Discovery.WindowSeconds, path); return new SecurityCheckResult @@ -253,7 +114,7 @@ private async Task CheckDiscoveryRateLimitAsync(string ipAd }; } - // Check per-model capability rate limiting for capability endpoints + // Check per-model capability rate limiting if (path.Contains("/capabilities/", StringComparison.OrdinalIgnoreCase)) { var modelMatch = ExtractModelFromPath(path); @@ -267,26 +128,20 @@ private async Task CheckDiscoveryRateLimitAsync(string ipAd } } - // Increment discovery counter await IncrementRateLimitCountAsync(discoveryKey, _options.RateLimiting.Discovery.WindowSeconds); - - return new SecurityCheckResult { IsAllowed = true }; + return SecurityCheckResult.Allowed(); } - /// - /// Checks per-model capability rate limits - /// private async Task CheckModelCapabilityRateLimitAsync(string ipAddress, string modelName) { - var capabilityKey = $"{RATE_LIMIT_PREFIX}capability:{ipAddress}:{modelName}"; - var now = DateTime.UtcNow; + var capabilityKey = $"{RateLimitPrefix}capability:{ipAddress}:{modelName}"; var capabilityCount = await GetRateLimitCountAsync(capabilityKey, _options.RateLimiting.Discovery.CapabilityCheckWindowSeconds); capabilityCount++; if (capabilityCount > _options.RateLimiting.Discovery.MaxCapabilityChecksPerModel) { - _logger.LogWarning("Model capability rate limit exceeded for {IpAddress} and model {Model}: {Count} requests in {Window} seconds", + Logger.LogWarning("Model capability rate limit exceeded for {IpAddress} and model {Model}: {Count} requests in {Window} seconds", ipAddress, modelName, capabilityCount, _options.RateLimiting.Discovery.CapabilityCheckWindowSeconds); return new SecurityCheckResult @@ -304,25 +159,19 @@ private async Task CheckModelCapabilityRateLimitAsync(strin }; } - // Increment capability counter await IncrementRateLimitCountAsync(capabilityKey, _options.RateLimiting.Discovery.CapabilityCheckWindowSeconds); - - return new SecurityCheckResult { IsAllowed = true }; + return SecurityCheckResult.Allowed(); } - /// - /// Extracts model name from capability path - /// - private string ExtractModelFromPath(string path) + private static string ExtractModelFromPath(string path) { try { - // Match patterns like /v1/discovery/models/{model}/capabilities/{capability} var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < segments.Length - 2; i++) { - if (segments[i].Equals("models", StringComparison.OrdinalIgnoreCase) && - i + 2 < segments.Length && + if (segments[i].Equals("models", StringComparison.OrdinalIgnoreCase) && + i + 2 < segments.Length && segments[i + 2].Equals("capabilities", StringComparison.OrdinalIgnoreCase)) { return segments[i + 1]; @@ -336,4 +185,4 @@ private string ExtractModelFromPath(string path) } } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRAcknowledgmentService.cs b/Services/ConduitLLM.Gateway/Services/SignalRAcknowledgmentService.cs index 8ea2ed91b..f56a8f635 100644 --- a/Services/ConduitLLM.Gateway/Services/SignalRAcknowledgmentService.cs +++ b/Services/ConduitLLM.Gateway/Services/SignalRAcknowledgmentService.cs @@ -1,8 +1,12 @@ +using System.Collections.Concurrent; +using System.Text.Json; + using ConduitLLM.Configuration.Services; +using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Models.SignalR; using ConduitLLM.Gateway.Models; using StackExchange.Redis; -using System.Text.Json; namespace ConduitLLM.Gateway.Services { @@ -43,23 +47,31 @@ public interface ISignalRAcknowledgmentService } /// - /// Implementation of SignalR acknowledgment service using Redis + /// Implementation of SignalR acknowledgment service using Redis. + /// Uses periodic timer scanning for timeouts instead of per-message Tasks to prevent memory leaks. /// public class SignalRAcknowledgmentService : ISignalRAcknowledgmentService, IHostedService, IDisposable { private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly RedisConnectionFactory _redisConnectionFactory; - + private Timer? _cleanupTimer; + private Timer? _timeoutScanTimer; private IDatabase? _redis; - + // Redis keys private readonly string _pendingAcknowledgmentsKey; private readonly string _connectionMessagesKeyPrefix; - + + // Local timeout tracking - tracks messageId -> timeout time + // This prevents creating one Task per message for timeout handling + private readonly ConcurrentDictionary _pendingTimeouts = new(); + private readonly SemaphoreSlim _timeoutScanLock = new(1, 1); + private readonly TimeSpan _defaultTimeout; private readonly TimeSpan _cleanupInterval; + private readonly TimeSpan _timeoutScanInterval; private readonly int _maxRetryAttempts; public SignalRAcknowledgmentService( @@ -72,30 +84,40 @@ public SignalRAcknowledgmentService( _redisConnectionFactory = redisConnectionFactory; // Redis keys - _pendingAcknowledgmentsKey = "signalr:acknowledgments"; - _connectionMessagesKeyPrefix = "signalr:conn_msgs"; + _pendingAcknowledgmentsKey = RedisKeys.SignalR.PendingAcknowledgments; + _connectionMessagesKeyPrefix = RedisKeys.SignalR.ConnectionMessagesPrefix; _defaultTimeout = TimeSpan.FromSeconds(configuration.GetValue("SignalR:Acknowledgment:TimeoutSeconds", 30)); _cleanupInterval = TimeSpan.FromMinutes(configuration.GetValue("SignalR:Acknowledgment:CleanupIntervalMinutes", 5)); + _timeoutScanInterval = TimeSpan.FromSeconds(configuration.GetValue("SignalR:Acknowledgment:TimeoutScanIntervalSeconds", 3)); _maxRetryAttempts = configuration.GetValue("SignalR:Acknowledgment:MaxRetryAttempts", 3); } public async Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("SignalR Acknowledgment Service starting"); - + try { var connection = await _redisConnectionFactory.GetConnectionAsync(); _redis = connection.GetDatabase(); - + _cleanupTimer = new Timer( CleanupExpiredAcknowledgments, null, _cleanupInterval, _cleanupInterval); - _logger.LogInformation("SignalR Acknowledgment Service started with Redis backend"); + // Start periodic timeout scanner - replaces per-message Task.Run + _timeoutScanTimer = new Timer( + _ => _ = ScanForTimeoutsAsync(), + null, + _timeoutScanInterval, + _timeoutScanInterval); + + _logger.LogInformation( + "SignalR Acknowledgment Service started with Redis backend (timeout scan interval: {Interval}s)", + _timeoutScanInterval.TotalSeconds); } catch (Exception ex) { @@ -107,11 +129,12 @@ public async Task StartAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("SignalR Acknowledgment Service stopping"); - + _cleanupTimer?.Change(Timeout.Infinite, 0); + _timeoutScanTimer?.Change(Timeout.Infinite, 0); - // TODO: Cancel pending acknowledgments from Redis if needed - // For now, they will timeout naturally or be processed by other instances + // Clear local timeout tracking - Redis will handle timeouts naturally via TTL + _pendingTimeouts.Clear(); return Task.CompletedTask; } @@ -159,19 +182,9 @@ public async Task RegisterMessageAsync( await _redis.SetAddAsync(connectionKey, message.MessageId); await _redis.KeyExpireAsync(connectionKey, TimeSpan.FromHours(1)); // Cleanup connection tracking - // Schedule timeout handling - _ = Task.Run(async () => - { - try - { - await Task.Delay(effectiveTimeout, pending.TimeoutTokenSource.Token); - await HandleTimeoutAsync(message.MessageId); - } - catch (TaskCanceledException) - { - // Expected when acknowledgment is received before timeout - } - }); + // Track timeout locally for periodic scanning - replaces per-message Task.Run + // The periodic ScanForTimeoutsAsync will handle expired messages + _pendingTimeouts.TryAdd(message.MessageId, timeoutAt); _logger.LogDebug( "Registered message {MessageId} for acknowledgment on {HubName}.{MethodName} to {ConnectionId}, timeout at {TimeoutAt}", @@ -233,7 +246,10 @@ public async Task AcknowledgeMessageAsync(string messageId, string connect var connectionKey = $"{_connectionMessagesKeyPrefix}:{connectionId}"; await _redis.SetRemoveAsync(connectionKey, messageId); - _logger.LogDebug( + // Remove from local timeout tracking + _pendingTimeouts.TryRemove(messageId, out _); + + _logger.LogInformation( "Message {MessageId} acknowledged by {ConnectionId}, RTT: {RoundTripTime}ms", messageId, connectionId, pending.RoundTripTime?.TotalMilliseconds ?? 0); @@ -294,6 +310,9 @@ public async Task NackMessageAsync(string messageId, string connectionId, var connectionKey = $"{_connectionMessagesKeyPrefix}:{connectionId}"; await _redis.SetRemoveAsync(connectionKey, messageId); + // Remove from local timeout tracking + _pendingTimeouts.TryRemove(messageId, out _); + _logger.LogWarning( "Message {MessageId} negatively acknowledged by {ConnectionId}: {ErrorMessage}", messageId, connectionId, errorMessage ?? "No error message provided"); @@ -414,11 +433,14 @@ public async Task CleanupConnectionAsync(string connectionId) foreach (var messageId in messageIds) { + // Remove from local timeout tracking + _pendingTimeouts.TryRemove(messageId.ToString(), out _); + try { var key = $"{_pendingAcknowledgmentsKey}:{messageId}"; var pendingData = await _redis.StringGetAsync(key); - + if (pendingData.HasValue) { var pending = JsonSerializer.Deserialize(pendingData.ToString()); @@ -455,6 +477,9 @@ public async Task CleanupConnectionAsync(string connectionId) private async Task HandleTimeoutAsync(string messageId) { + // Ensure removed from local tracking (may already be removed by ScanForTimeoutsAsync) + _pendingTimeouts.TryRemove(messageId, out _); + if (_redis == null) { return; @@ -464,7 +489,7 @@ private async Task HandleTimeoutAsync(string messageId) { var key = $"{_pendingAcknowledgmentsKey}:{messageId}"; var pendingData = await _redis.StringGetAsync(key); - + if (!pendingData.HasValue) { return; // Already processed or expired @@ -488,7 +513,7 @@ private async Task HandleTimeoutAsync(string messageId) _logger.LogWarning( "Message {MessageId} timed out after {Timeout}ms on {HubName}.{MethodName} to {ConnectionId}", - messageId, + messageId, (DateTime.UtcNow - pending.SentAt).TotalMilliseconds, pending.HubName, pending.MethodName, @@ -513,22 +538,75 @@ private void CleanupExpiredAcknowledgments(object? state) { // Redis TTL automatically handles cleanup of expired acknowledgments // This cleanup is mainly handled by Redis expiration, so minimal work needed here - + try { - _logger.LogTrace("Acknowledgment cleanup timer executed - Redis handles TTL automatically"); + _logger.LogDebug("Acknowledgment cleanup timer executed - Redis handles TTL automatically"); } catch (Exception ex) { - _logger.LogError(ex, "Error during acknowledgment cleanup"); + _logger.LogError(ex, "Error during acknowledgment cleanup, pending count: {PendingCount}", _pendingTimeouts.Count); } } + /// + /// Periodically scans for timed-out messages and handles them in batches. + /// This replaces per-message Task.Run to prevent memory leaks under high load. + /// + private async Task ScanForTimeoutsAsync() + { + if (!await _timeoutScanLock.WaitAsync(0)) + { + // Another scan is in progress + return; + } + + try + { + var now = DateTime.UtcNow; + + // Find all expired messages + var timedOutIds = _pendingTimeouts + .Where(kvp => kvp.Value <= now) + .Select(kvp => kvp.Key) + .ToList(); + + if (timedOutIds.Count > 0) + { + _logger.LogInformation("Timeout scan found {Count} expired messages", timedOutIds.Count); + } + + foreach (var messageId in timedOutIds) + { + // Remove from local tracking first + _pendingTimeouts.TryRemove(messageId, out _); + + try + { + await HandleTimeoutAsync(messageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling timeout for message {MessageId}", messageId); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during timeout scan, pending count: {PendingCount}", _pendingTimeouts.Count); + } + finally + { + _timeoutScanLock.Release(); + } + } public void Dispose() { _cleanupTimer?.Dispose(); - // Redis handles cleanup automatically via TTL + _timeoutScanTimer?.Dispose(); + _timeoutScanLock?.Dispose(); + _pendingTimeouts.Clear(); } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Cleanup.cs b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Cleanup.cs new file mode 100644 index 000000000..33a097bb6 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Cleanup.cs @@ -0,0 +1,120 @@ +using System.Collections.Concurrent; + +using StackExchange.Redis; + +using SignalRConnectionInfo = ConduitLLM.Gateway.Models.ConnectionInfo; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRConnectionMonitor + { + private void CleanupStaleConnections(object? state) + { + // Fire-and-forget with proper exception handling - don't use async void + _ = CleanupStaleConnectionsAsync(); + } + + private async Task CleanupStaleConnectionsAsync() + { + if (_redis == null) + { + return; + } + + try + { + var allConnections = await GetAllConnectionsFromRedisAsync(); + List staleConnections = [ + ..allConnections.Where(c => c.IsStale(_staleConnectionThreshold)) + ]; + + var cleanupTasks = new List(); + foreach (var connection in staleConnections) + { + cleanupTasks.Add(CleanupStaleConnectionAsync(connection)); + } + + await Task.WhenAll(cleanupTasks); + + // Clean up empty groups + var emptyGroupCount = await CleanupEmptyGroupsAsync(); + + if (staleConnections.Count > 0) + { + _logger.LogInformation( + "Cleaned up {Count} stale connections and {GroupCount} empty groups", + staleConnections.Count, emptyGroupCount); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during stale connection cleanup"); + } + } + + private async Task CleanupStaleConnectionAsync(SignalRConnectionInfo connection) + { + try + { + // Remove from connections hash + await _redis!.HashDeleteAsync(_connectionsKey, connection.ConnectionId); + + // Remove from all groups + if (_server != null) + { + var removalTasks = new List(); + foreach (var groupKey in _server.Keys(pattern: $"{_groupConnectionsKeyPrefix}:*")) + { + removalTasks.Add(_redis.SetRemoveAsync(groupKey, connection.ConnectionId)); + } + await Task.WhenAll(removalTasks); + } + + _logger.LogWarning( + "Cleaned up stale connection {ConnectionId} from {HubName} (idle for {IdleMinutes}min)", + connection.ConnectionId, + connection.HubName, + connection.IdleTime.TotalMinutes); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cleanup stale connection {ConnectionId}", connection.ConnectionId); + } + } + + private async Task CleanupEmptyGroupsAsync() + { + try + { + if (_server != null) + { + var allGroupKeys = _server.Keys(pattern: $"{_groupConnectionsKeyPrefix}:*").ToArray(); + var emptyGroups = new ConcurrentBag(); + var checkTasks = allGroupKeys.Select(async groupKey => + { + var count = await _redis!.SetLengthAsync(groupKey); + if (count == 0) + { + emptyGroups.Add(groupKey); + } + }); + + await Task.WhenAll(checkTasks); + + if (!emptyGroups.IsEmpty) + { + await _redis!.KeyDeleteAsync(emptyGroups.ToArray()); + } + + return emptyGroups.Count; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cleanup empty groups from prefix {Prefix}", _groupConnectionsKeyPrefix); + } + + return 0; + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Groups.cs b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Groups.cs new file mode 100644 index 000000000..43f7e7e1a --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Groups.cs @@ -0,0 +1,81 @@ +using System.Text.Json; + +using SignalRConnectionInfo = ConduitLLM.Gateway.Models.ConnectionInfo; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRConnectionMonitor + { + public async Task AddToGroupAsync(string connectionId, string groupName) + { + if (_redis == null) + { + return; + } + + try + { + // Update connection info to include the group + var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId); + if (connectionData.HasValue) + { + var connectionInfo = JsonSerializer.Deserialize(connectionData.ToString()); + if (connectionInfo != null) + { + connectionInfo.Groups.Add(groupName); + var updatedData = JsonSerializer.Serialize(connectionInfo); + await _redis.HashSetAsync(_connectionsKey, connectionId, updatedData); + } + } + + // Add connection to group set + var groupKey = $"{_groupConnectionsKeyPrefix}:{groupName}"; + await _redis.SetAddAsync(groupKey, connectionId); + + _logger.LogDebug( + "Connection {ConnectionId} added to group {GroupName}", + connectionId, groupName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add connection {ConnectionId} to group {GroupName}", connectionId, groupName); + } + } + + public async Task RemoveFromGroupAsync(string connectionId, string groupName) + { + if (_redis == null) + { + return; + } + + try + { + // Update connection info to remove the group + var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId); + if (connectionData.HasValue) + { + var connectionInfo = JsonSerializer.Deserialize(connectionData.ToString()); + if (connectionInfo != null) + { + connectionInfo.Groups.Remove(groupName); + var updatedData = JsonSerializer.Serialize(connectionInfo); + await _redis.HashSetAsync(_connectionsKey, connectionId, updatedData); + } + } + + // Remove connection from group set + var groupKey = $"{_groupConnectionsKeyPrefix}:{groupName}"; + await _redis.SetRemoveAsync(groupKey, connectionId); + + _logger.LogDebug( + "Connection {ConnectionId} removed from group {GroupName}", + connectionId, groupName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove connection {ConnectionId} from group {GroupName}", connectionId, groupName); + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Queries.cs b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Queries.cs new file mode 100644 index 000000000..db334395e --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.Queries.cs @@ -0,0 +1,296 @@ +using ConduitLLM.Gateway.Models; + +using StackExchange.Redis; +using System.Text.Json; + +using SignalRConnectionInfo = ConduitLLM.Gateway.Models.ConnectionInfo; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRConnectionMonitor + { + public async Task GetConnectionAsync(string connectionId) + { + if (_redis == null) + { + return null; + } + + try + { + var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId); + if (connectionData.HasValue) + { + return JsonSerializer.Deserialize(connectionData.ToString()); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get connection {ConnectionId}", connectionId); + } + + return null; + } + + public async Task> GetActiveConnectionsAsync() + { + if (_redis == null) + { + return Enumerable.Empty(); + } + + try + { + var allConnections = await _redis.HashGetAllAsync(_connectionsKey); + var activeConnections = new List(); + + foreach (var connectionData in allConnections) + { + try + { + var connectionInfo = JsonSerializer.Deserialize(connectionData.Value!.ToString()); + if (connectionInfo != null && !connectionInfo.IsStale(_staleConnectionThreshold)) + { + activeConnections.Add(connectionInfo); + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionData.Name); + } + } + + return activeConnections; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get active connections"); + return Enumerable.Empty(); + } + } + + public async Task> GetHubConnectionsAsync(string hubName) + { + if (_redis == null) + { + return Enumerable.Empty(); + } + + try + { + var allConnections = await _redis.HashGetAllAsync(_connectionsKey); + var hubConnections = new List(); + + foreach (var connectionData in allConnections) + { + try + { + var connectionInfo = JsonSerializer.Deserialize(connectionData.Value!.ToString()); + if (connectionInfo != null && connectionInfo.HubName == hubName && !connectionInfo.IsStale(_staleConnectionThreshold)) + { + hubConnections.Add(connectionInfo); + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionData.Name); + } + } + + return hubConnections; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get hub connections for {HubName}", hubName); + return Enumerable.Empty(); + } + } + + public async Task> GetVirtualKeyConnectionsAsync(int virtualKeyId) + { + if (_redis == null) + { + return Enumerable.Empty(); + } + + try + { + var allConnections = await _redis.HashGetAllAsync(_connectionsKey); + var virtualKeyConnections = new List(); + + foreach (var connectionData in allConnections) + { + try + { + var connectionInfo = JsonSerializer.Deserialize(connectionData.Value!.ToString()); + if (connectionInfo != null && connectionInfo.VirtualKeyId == virtualKeyId && !connectionInfo.IsStale(_staleConnectionThreshold)) + { + virtualKeyConnections.Add(connectionInfo); + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionData.Name); + } + } + + return virtualKeyConnections; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get virtual key connections for {VirtualKeyId}", virtualKeyId); + return Enumerable.Empty(); + } + } + + public async Task> GetGroupConnectionsAsync(string groupName) + { + if (_redis == null) + { + return Enumerable.Empty(); + } + + try + { + var groupKey = $"{_groupConnectionsKeyPrefix}:{groupName}"; + var connectionIds = await _redis.SetMembersAsync(groupKey); + + var groupConnections = new List(); + var tasks = connectionIds.Select(async connectionId => + { + try + { + var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId!); + if (connectionData.HasValue) + { + var connectionInfo = JsonSerializer.Deserialize(connectionData.ToString()); + if (connectionInfo != null && !connectionInfo.IsStale(_staleConnectionThreshold)) + { + return connectionInfo; + } + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionId); + } + return null; + }); + + var results = await Task.WhenAll(tasks); + return results.Where(c => c != null).Cast(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get group connections for {GroupName}", groupName); + return Enumerable.Empty(); + } + } + + private async Task> GetAllConnectionsFromRedisAsync() + { + var allConnections = new List(); + + try + { + var connectionData = await _redis!.HashGetAllAsync(_connectionsKey); + foreach (var data in connectionData) + { + try + { + var connectionInfo = JsonSerializer.Deserialize(data.Value!.ToString()); + if (connectionInfo != null) + { + allConnections.Add(connectionInfo); + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", data.Name); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get all connections from Redis"); + } + + return allConnections; + } + + public async Task GetStatisticsAsync() + { + if (_redis == null) + { + return new ConnectionStatistics(); + } + + try + { + var allConnections = await GetAllConnectionsFromRedisAsync(); + List activeConnections = [..allConnections.Where(c => !c.IsStale(_staleConnectionThreshold))]; + + var stats = new ConnectionStatistics + { + TotalActiveConnections = activeConnections.Count, + StaleConnections = allConnections.Count - activeConnections.Count, + TotalGroups = await GetGroupCountAsync(), + TotalMessagesSent = allConnections.Sum(c => c.MessagesSent), + TotalMessagesAcknowledged = allConnections.Sum(c => c.MessagesAcknowledged) + }; + + // Connections by hub + stats.ConnectionsByHub = activeConnections + .GroupBy(c => c.HubName) + .ToDictionary(g => g.Key, g => g.Count()); + + // Connections by transport + stats.ConnectionsByTransport = activeConnections + .Where(c => c.TransportType != null) + .GroupBy(c => c.TransportType!) + .ToDictionary(g => g.Key, g => g.Count()); + + if (activeConnections.Count > 0) + { + stats.AverageConnectionDurationMinutes = activeConnections + .Average(c => c.ConnectionDuration.TotalMinutes); + stats.AverageIdleTimeMinutes = activeConnections + .Average(c => c.IdleTime.TotalMinutes); + stats.OldestConnectionTime = activeConnections + .Min(c => c.ConnectedAt); + stats.NewestConnectionTime = activeConnections + .Max(c => c.ConnectedAt); + } + + if (stats.TotalMessagesSent > 0) + { + stats.AcknowledgmentRate = (double)stats.TotalMessagesAcknowledged / stats.TotalMessagesSent * 100; + } + + return stats; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get connection statistics"); + return new ConnectionStatistics(); + } + } + + private Task GetGroupCountAsync() + { + try + { + if (_server != null) + { + return Task.FromResult(_server.Keys(pattern: $"{_groupConnectionsKeyPrefix}:*").Count()); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get group count from Redis using pattern {Pattern}", $"{_groupConnectionsKeyPrefix}:*"); + } + + return Task.FromResult(0); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.cs b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.cs index 29ef7c1bc..8da61111f 100644 --- a/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.cs +++ b/Services/ConduitLLM.Gateway/Services/SignalRConnectionMonitor.cs @@ -1,4 +1,6 @@ using ConduitLLM.Configuration.Services; +using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Extensions; using ConduitLLM.Gateway.Interfaces; using StackExchange.Redis; @@ -43,32 +45,32 @@ public interface ISignalRConnectionMonitor /// /// Gets information about a specific connection /// - ConduitLLM.Gateway.Models.ConnectionInfo? GetConnection(string connectionId); + Task GetConnectionAsync(string connectionId); /// /// Gets all active connections /// - IEnumerable GetActiveConnections(); + Task> GetActiveConnectionsAsync(); /// /// Gets connections for a specific hub /// - IEnumerable GetHubConnections(string hubName); + Task> GetHubConnectionsAsync(string hubName); /// /// Gets connections for a specific virtual key /// - IEnumerable GetVirtualKeyConnections(int virtualKeyId); + Task> GetVirtualKeyConnectionsAsync(int virtualKeyId); /// /// Gets connections in a specific group /// - IEnumerable GetGroupConnections(string groupName); + Task> GetGroupConnectionsAsync(string groupName); /// /// Gets monitoring statistics /// - ConnectionStatistics GetStatistics(); + Task GetStatisticsAsync(); /// /// Records a message sent to a connection @@ -103,7 +105,7 @@ public class ConnectionStatistics /// /// Implementation of SignalR connection monitor using Redis /// - public class SignalRConnectionMonitor : ISignalRConnectionMonitor, IHostedService, IDisposable + public partial class SignalRConnectionMonitor : ISignalRConnectionMonitor, IHostedService, IDisposable { private readonly ILogger _logger; private readonly IConfiguration _configuration; @@ -111,7 +113,8 @@ public class SignalRConnectionMonitor : ISignalRConnectionMonitor, IHostedServic private Timer? _cleanupTimer; private IDatabase? _redis; - + private IServer? _server; + // Redis keys private readonly string _connectionsKey; private readonly string _groupConnectionsKeyPrefix; @@ -129,8 +132,8 @@ public SignalRConnectionMonitor( _redisConnectionFactory = redisConnectionFactory; // Redis keys - _connectionsKey = "signalr:connections"; - _groupConnectionsKeyPrefix = "signalr:groups"; + _connectionsKey = RedisKeys.SignalR.ActiveConnections; + _groupConnectionsKeyPrefix = RedisKeys.SignalR.GroupConnectionsPrefix; _staleConnectionThreshold = TimeSpan.FromMinutes( configuration.GetValue("SignalR:ConnectionMonitor:StaleThresholdMinutes", 60)); @@ -146,6 +149,7 @@ public async Task StartAsync(CancellationToken cancellationToken) { var connection = await _redisConnectionFactory.GetConnectionAsync(); _redis = connection.GetDatabase(); + _server = connection.GetPrimaryServer(); _cleanupTimer = new Timer( CleanupStaleConnections, @@ -213,7 +217,7 @@ public async Task OnConnectionAsync(string connectionId, string hubName, HubCall } catch (Exception ex) { - _logger.LogError(ex, "Failed to track connection {ConnectionId}", connectionId); + _logger.LogError(ex, "Failed to track connection {ConnectionId} on hub {HubName}", connectionId, hubName); // Don't throw - connection tracking failure shouldn't break the connection } } @@ -248,16 +252,12 @@ public async Task OnDisconnectionAsync(string connectionId) await _redis.HashDeleteAsync(_connectionsKey, connectionId); // Remove from all groups - scan group keys for this connection - var groupKeys = await _redis.ExecuteAsync("KEYS", $"{_groupConnectionsKeyPrefix}:*"); - if (groupKeys.Resp2Type == ResultType.Array) + if (_server != null) { var tasks = new List(); - foreach (var groupKey in (RedisResult[]?)groupKeys ?? Array.Empty()) + foreach (var groupKey in _server.Keys(pattern: $"{_groupConnectionsKeyPrefix}:*")) { - if (groupKey.ToString() is { } keyStr) - { - tasks.Add(_redis.SetRemoveAsync(keyStr, connectionId)); - } + tasks.Add(_redis.SetRemoveAsync(groupKey, connectionId)); } await Task.WhenAll(tasks); } @@ -310,78 +310,6 @@ public async Task RecordActivityAsync(string connectionId) } } - public async Task AddToGroupAsync(string connectionId, string groupName) - { - if (_redis == null) - { - return; - } - - try - { - // Update connection info to include the group - var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId); - if (connectionData.HasValue) - { - var connectionInfo = JsonSerializer.Deserialize(connectionData.ToString()); - if (connectionInfo != null) - { - connectionInfo.Groups.Add(groupName); - var updatedData = JsonSerializer.Serialize(connectionInfo); - await _redis.HashSetAsync(_connectionsKey, connectionId, updatedData); - } - } - - // Add connection to group set - var groupKey = $"{_groupConnectionsKeyPrefix}:{groupName}"; - await _redis.SetAddAsync(groupKey, connectionId); - - _logger.LogDebug( - "Connection {ConnectionId} added to group {GroupName}", - connectionId, groupName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to add connection {ConnectionId} to group {GroupName}", connectionId, groupName); - } - } - - public async Task RemoveFromGroupAsync(string connectionId, string groupName) - { - if (_redis == null) - { - return; - } - - try - { - // Update connection info to remove the group - var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId); - if (connectionData.HasValue) - { - var connectionInfo = JsonSerializer.Deserialize(connectionData.ToString()); - if (connectionInfo != null) - { - connectionInfo.Groups.Remove(groupName); - var updatedData = JsonSerializer.Serialize(connectionInfo); - await _redis.HashSetAsync(_connectionsKey, connectionId, updatedData); - } - } - - // Remove connection from group set - var groupKey = $"{_groupConnectionsKeyPrefix}:{groupName}"; - await _redis.SetRemoveAsync(groupKey, connectionId); - - _logger.LogDebug( - "Connection {ConnectionId} removed from group {GroupName}", - connectionId, groupName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to remove connection {ConnectionId} from group {GroupName}", connectionId, groupName); - } - } - public async Task RecordMessageSentAsync(string connectionId) { if (_redis == null) @@ -438,440 +366,6 @@ public async Task RecordMessageAcknowledgedAsync(string connectionId) } } - public async Task GetConnectionAsync(string connectionId) - { - if (_redis == null) - { - return null; - } - - try - { - var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId); - if (connectionData.HasValue) - { - return JsonSerializer.Deserialize(connectionData.ToString()); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get connection {ConnectionId}", connectionId); - } - - return null; - } - - public SignalRConnectionInfo? GetConnection(string connectionId) - { - // Synchronous wrapper for backward compatibility - return GetConnectionAsync(connectionId).GetAwaiter().GetResult(); - } - - public async Task> GetActiveConnectionsAsync() - { - if (_redis == null) - { - return Enumerable.Empty(); - } - - try - { - var allConnections = await _redis.HashGetAllAsync(_connectionsKey); - var activeConnections = new List(); - - foreach (var connectionData in allConnections) - { - try - { - var connectionInfo = JsonSerializer.Deserialize(connectionData.Value!.ToString()); - if (connectionInfo != null && !connectionInfo.IsStale(_staleConnectionThreshold)) - { - activeConnections.Add(connectionInfo); - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionData.Name); - } - } - - return activeConnections; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get active connections"); - return Enumerable.Empty(); - } - } - - public IEnumerable GetActiveConnections() - { - // Synchronous wrapper for backward compatibility - return GetActiveConnectionsAsync().GetAwaiter().GetResult(); - } - - public IEnumerable GetHubConnections(string hubName) - { - // Synchronous wrapper for backward compatibility - return GetHubConnectionsAsync(hubName).GetAwaiter().GetResult(); - } - - public async Task> GetHubConnectionsAsync(string hubName) - { - if (_redis == null) - { - return Enumerable.Empty(); - } - - try - { - var allConnections = await _redis.HashGetAllAsync(_connectionsKey); - var hubConnections = new List(); - - foreach (var connectionData in allConnections) - { - try - { - var connectionInfo = JsonSerializer.Deserialize(connectionData.Value!.ToString()); - if (connectionInfo != null && connectionInfo.HubName == hubName && !connectionInfo.IsStale(_staleConnectionThreshold)) - { - hubConnections.Add(connectionInfo); - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionData.Name); - } - } - - return hubConnections; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get hub connections for {HubName}", hubName); - return Enumerable.Empty(); - } - } - - public IEnumerable GetVirtualKeyConnections(int virtualKeyId) - { - // Synchronous wrapper for backward compatibility - return GetVirtualKeyConnectionsAsync(virtualKeyId).GetAwaiter().GetResult(); - } - - public async Task> GetVirtualKeyConnectionsAsync(int virtualKeyId) - { - if (_redis == null) - { - return Enumerable.Empty(); - } - - try - { - var allConnections = await _redis.HashGetAllAsync(_connectionsKey); - var virtualKeyConnections = new List(); - - foreach (var connectionData in allConnections) - { - try - { - var connectionInfo = JsonSerializer.Deserialize(connectionData.Value!.ToString()); - if (connectionInfo != null && connectionInfo.VirtualKeyId == virtualKeyId && !connectionInfo.IsStale(_staleConnectionThreshold)) - { - virtualKeyConnections.Add(connectionInfo); - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionData.Name); - } - } - - return virtualKeyConnections; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get virtual key connections for {VirtualKeyId}", virtualKeyId); - return Enumerable.Empty(); - } - } - - public IEnumerable GetGroupConnections(string groupName) - { - // Synchronous wrapper for backward compatibility - return GetGroupConnectionsAsync(groupName).GetAwaiter().GetResult(); - } - - public async Task> GetGroupConnectionsAsync(string groupName) - { - if (_redis == null) - { - return Enumerable.Empty(); - } - - try - { - var groupKey = $"{_groupConnectionsKeyPrefix}:{groupName}"; - var connectionIds = await _redis.SetMembersAsync(groupKey); - - var groupConnections = new List(); - var tasks = connectionIds.Select(async connectionId => - { - try - { - var connectionData = await _redis.HashGetAsync(_connectionsKey, connectionId!); - if (connectionData.HasValue) - { - var connectionInfo = JsonSerializer.Deserialize(connectionData.ToString()); - if (connectionInfo != null && !connectionInfo.IsStale(_staleConnectionThreshold)) - { - return connectionInfo; - } - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", connectionId); - } - return null; - }); - - var results = await Task.WhenAll(tasks); - return results.Where(c => c != null).Cast(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get group connections for {GroupName}", groupName); - return Enumerable.Empty(); - } - } - - public ConnectionStatistics GetStatistics() - { - // Synchronous wrapper for backward compatibility - return GetStatisticsAsync().GetAwaiter().GetResult(); - } - - public async Task GetStatisticsAsync() - { - if (_redis == null) - { - return new ConnectionStatistics(); - } - - try - { - var allConnections = await GetAllConnectionsFromRedisAsync(); - List activeConnections = [..allConnections.Where(c => !c.IsStale(_staleConnectionThreshold))]; - - var stats = new ConnectionStatistics - { - TotalActiveConnections = activeConnections.Count, - StaleConnections = allConnections.Count - activeConnections.Count, - TotalGroups = await GetGroupCountAsync(), - TotalMessagesSent = allConnections.Sum(c => c.MessagesSent), - TotalMessagesAcknowledged = allConnections.Sum(c => c.MessagesAcknowledged) - }; - - // Connections by hub - stats.ConnectionsByHub = activeConnections - .GroupBy(c => c.HubName) - .ToDictionary(g => g.Key, g => g.Count()); - - // Connections by transport - stats.ConnectionsByTransport = activeConnections - .Where(c => c.TransportType != null) - .GroupBy(c => c.TransportType!) - .ToDictionary(g => g.Key, g => g.Count()); - - if (activeConnections.Count > 0) - { - stats.AverageConnectionDurationMinutes = activeConnections - .Average(c => c.ConnectionDuration.TotalMinutes); - stats.AverageIdleTimeMinutes = activeConnections - .Average(c => c.IdleTime.TotalMinutes); - stats.OldestConnectionTime = activeConnections - .Min(c => c.ConnectedAt); - stats.NewestConnectionTime = activeConnections - .Max(c => c.ConnectedAt); - } - - if (stats.TotalMessagesSent > 0) - { - stats.AcknowledgmentRate = (double)stats.TotalMessagesAcknowledged / stats.TotalMessagesSent * 100; - } - - return stats; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get connection statistics"); - return new ConnectionStatistics(); - } - } - - private async Task> GetAllConnectionsFromRedisAsync() - { - var allConnections = new List(); - - try - { - var connectionData = await _redis!.HashGetAllAsync(_connectionsKey); - foreach (var data in connectionData) - { - try - { - var connectionInfo = JsonSerializer.Deserialize(data.Value!.ToString()); - if (connectionInfo != null) - { - allConnections.Add(connectionInfo); - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize connection info for {ConnectionId}", data.Name); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get all connections from Redis"); - } - - return allConnections; - } - - private async Task GetGroupCountAsync() - { - try - { - var groupKeys = await _redis!.ExecuteAsync("KEYS", $"{_groupConnectionsKeyPrefix}:*"); - if (groupKeys.Resp2Type == ResultType.Array) - { - return ((RedisResult[]?)groupKeys)?.Length ?? 0; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get group count from Redis"); - } - - return 0; - } - - private async void CleanupStaleConnections(object? state) - { - if (_redis == null) - { - return; - } - - try - { - var allConnections = await GetAllConnectionsFromRedisAsync(); - List staleConnections = [ - ..allConnections.Where(c => c.IsStale(_staleConnectionThreshold)) - ]; - - var cleanupTasks = new List(); - foreach (var connection in staleConnections) - { - cleanupTasks.Add(CleanupStaleConnectionAsync(connection)); - } - - await Task.WhenAll(cleanupTasks); - - // Clean up empty groups - var emptyGroupCount = await CleanupEmptyGroupsAsync(); - - if (staleConnections.Count > 0) - { - _logger.LogInformation( - "Cleaned up {Count} stale connections and {GroupCount} empty groups", - staleConnections.Count, emptyGroupCount); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during stale connection cleanup"); - } - } - - private async Task CleanupStaleConnectionAsync(SignalRConnectionInfo connection) - { - try - { - // Remove from connections hash - await _redis!.HashDeleteAsync(_connectionsKey, connection.ConnectionId); - - // Remove from all groups - var groupKeys = await _redis.ExecuteAsync("KEYS", $"{_groupConnectionsKeyPrefix}:*"); - if (groupKeys.Resp2Type == ResultType.Array) - { - var removalTasks = new List(); - foreach (var groupKey in (RedisResult[]?)groupKeys ?? Array.Empty()) - { - if (groupKey.ToString() is { } keyStr) - { - removalTasks.Add(_redis.SetRemoveAsync(keyStr, connection.ConnectionId)); - } - } - await Task.WhenAll(removalTasks); - } - - _logger.LogWarning( - "Cleaned up stale connection {ConnectionId} from {HubName} (idle for {IdleMinutes}min)", - connection.ConnectionId, - connection.HubName, - connection.IdleTime.TotalMinutes); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to cleanup stale connection {ConnectionId}", connection.ConnectionId); - } - } - - private async Task CleanupEmptyGroupsAsync() - { - try - { - var groupKeys = await _redis!.ExecuteAsync("KEYS", $"{_groupConnectionsKeyPrefix}:*"); - if (groupKeys.Resp2Type == ResultType.Array) - { - var emptyGroups = new List(); - var checkTasks = ((RedisResult[]?)groupKeys ?? Array.Empty()).Select(async groupKey => - { - if (groupKey.ToString() is { } keyStr) - { - var count = await _redis.SetLengthAsync(keyStr); - if (count == 0) - { - lock (emptyGroups) - { - emptyGroups.Add(keyStr); - } - } - } - }); - - await Task.WhenAll(checkTasks); - - if (emptyGroups.Count > 0) - { - await _redis.KeyDeleteAsync([..emptyGroups.Select(g => (RedisKey)g)]); - } - - return emptyGroups.Count; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to cleanup empty groups"); - } - - return 0; - } - public void Dispose() { _cleanupTimer?.Dispose(); diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Delivery.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Delivery.cs new file mode 100644 index 000000000..a20a7efd7 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Delivery.cs @@ -0,0 +1,158 @@ +using System.Text.Json; + +using ConduitLLM.Gateway.Models; + +using Microsoft.AspNetCore.SignalR; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRMessageBatcher + { + private async Task SendBatchAsync(BatchKey batchKey, MessageBatch batch, string batchKeyString) + { + if (_redis == null) + { + return; + } + + try + { + // Remove batch from active batches + var removed = await _redis.HashDeleteAsync(_activeBatchesKey, batchKeyString); + if (!removed) + { + return; // Batch already processed + } + + var batchLatency = DateTime.UtcNow - batch.CreatedAt; + + if (batch.Messages.Count == 0) + { + return; + } + + var messagesToSend = new List(batch.Messages); + var messageCount = messagesToSend.Count; + + try + { + using var scope = _serviceProvider.CreateScope(); + var hubContext = GetHubContext(scope, batch.HubName); + + if (hubContext == null) + { + _logger.LogError("Could not find hub context for {HubName}", batch.HubName); + return; + } + + // Create batched message + var batchedMessage = new BatchedMessage + { + Messages = messagesToSend, + MethodName = batch.MethodName, + HubName = batch.HubName, + ConnectionId = batch.ConnectionId, + GroupName = batch.GroupName, + TotalSizeBytes = batch.TotalSizeBytes, + Priority = batch.Priority, + ContainsCriticalMessages = batch.ContainsCriticalMessages + }; + + // Send based on target + if (!string.IsNullOrEmpty(batch.ConnectionId)) + { + await hubContext.Clients.Client(batch.ConnectionId) + .SendAsync($"{batch.MethodName}Batch", batchedMessage); + } + else if (!string.IsNullOrEmpty(batch.GroupName)) + { + await hubContext.Clients.Group(batch.GroupName) + .SendAsync($"{batch.MethodName}Batch", batchedMessage); + } + else + { + await hubContext.Clients.All + .SendAsync($"{batch.MethodName}Batch", batchedMessage); + } + + // Update statistics in Redis + await Task.WhenAll( + _redis.HashIncrementAsync(_statisticsKey, "totalBatchesSent"), + _redis.HashIncrementAsync(_statisticsKey, "totalBatchLatency", (long)batchLatency.TotalMilliseconds), + _redis.HashSetAsync(_statisticsKey, "lastBatchSentAt", DateTime.UtcNow.ToBinary().ToString()) + ); + + _logger.LogDebug( + "Sent batch of {Count} messages for {HubName}.{MethodName}, latency: {Latency}ms", + messageCount, batch.HubName, batch.MethodName, batchLatency.TotalMilliseconds); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error sending batch for {HubName}.{MethodName}", + batch.HubName, batch.MethodName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in SendBatchAsync for key {Key}, hub {HubName}.{MethodName}", batchKeyString, batch.HubName, batch.MethodName); + } + } + + private async Task SendMessageDirectlyAsync( + string hubName, + string methodName, + object message, + string? connectionId, + string? groupName) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var hubContext = GetHubContext(scope, hubName); + + if (hubContext == null) + { + _logger.LogError("Could not find hub context for {HubName}", hubName); + return; + } + + if (!string.IsNullOrEmpty(connectionId)) + { + await hubContext.Clients.Client(connectionId).SendAsync(methodName, message); + } + else if (!string.IsNullOrEmpty(groupName)) + { + await hubContext.Clients.Group(groupName).SendAsync(methodName, message); + } + else + { + await hubContext.Clients.All.SendAsync(methodName, message); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error sending message directly for {HubName}.{MethodName}", + hubName, methodName); + } + } + + private long EstimateMessageSize(object message) + { + try + { + var json = JsonSerializer.Serialize(message); + return json.Length * sizeof(char); + } + catch + { + // Fallback estimate + return 1024; + } + } + + private static IHubContext? GetHubContext(IServiceScope scope, string hubName) + => Utilities.SignalRHubContextResolver.Resolve(scope.ServiceProvider, hubName); + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Processing.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Processing.cs new file mode 100644 index 000000000..a3d6d25e2 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Processing.cs @@ -0,0 +1,167 @@ +using System.Text.Json; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRMessageBatcher + { + /// + /// Processes batch signals from the channel with proper error handling. + /// This replaces fire-and-forget Task.Run patterns. + /// + private async Task ProcessSignalsAsync(CancellationToken ct) + { + _logger.LogDebug("Signal processing task started"); + + try + { + await foreach (var signal in _signalChannel.Reader.ReadAllAsync(ct)) + { + try + { + switch (signal) + { + case BatchSignal.ProcessBatches: + await ProcessBatchesAsync(); + break; + case BatchSignal.FlushAll: + await FlushAllBatchesAsync(); + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing batch signal {Signal}", signal); + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + _logger.LogDebug("Signal processing task cancelled"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in signal processing task"); + } + + _logger.LogDebug("Signal processing task completed"); + } + + private void ProcessBatches(object? state) + { + // Signal to process batches via channel (replaces fire-and-forget Task.Run) + // The signal processing task handles this with proper error handling + _signalChannel.Writer.TryWrite(BatchSignal.ProcessBatches); + } + + private async Task ProcessBatchesAsync() + { + if (!await _batchProcessingLock.WaitAsync(0)) + { + // Already processing + return; + } + + try + { + var now = DateTime.UtcNow; + if (_redis == null) + { + return; + } + + var batchesToSend = new List<(BatchKey Key, MessageBatch Batch, string KeyString)>(); + + // Check all active batches + var activeBatches = await _redis.HashGetAllAsync(_activeBatchesKey); + foreach (var batchEntry in activeBatches) + { + try + { + var batch = JsonSerializer.Deserialize(batchEntry.Value!.ToString()); + if (batch != null && batch.Messages.Count > 0 && + (now - batch.CreatedAt >= _batchWindow || batch.IsQueued)) + { + var batchKey = new BatchKey(batch.HubName, batch.MethodName, batch.ConnectionId, batch.GroupName); + batchesToSend.Add((batchKey, batch, batchEntry.Name!)); + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize batch data for key {Key}", batchEntry.Name); + } + } + + // Send all ready batches + var sendTasks = batchesToSend.Select(item => SendBatchAsync(item.Key, item.Batch, item.KeyString)); + await Task.WhenAll(sendTasks); + + // Process explicitly queued batches + string? queuedBatchKey; + while ((queuedBatchKey = await _redis.ListLeftPopAsync(_batchQueueKey)) != null) + { + var batchData = await _redis.HashGetAsync(_activeBatchesKey, queuedBatchKey); + if (batchData.HasValue) + { + try + { + var batch = JsonSerializer.Deserialize(batchData.ToString()); + if (batch != null) + { + var batchKey = new BatchKey(batch.HubName, batch.MethodName, batch.ConnectionId, batch.GroupName); + await SendBatchAsync(batchKey, batch, queuedBatchKey); + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize queued batch data for key {Key}", queuedBatchKey); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing message batches"); + } + finally + { + _batchProcessingLock.Release(); + } + } + + private async Task GetOrCreateBatchAsync(string batchKeyString, BatchKey batchKey) + { + var batchData = await _redis!.HashGetAsync(_activeBatchesKey, batchKeyString); + + if (batchData.HasValue) + { + try + { + var existingBatch = JsonSerializer.Deserialize(batchData.ToString()); + if (existingBatch != null) + { + return existingBatch; + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize existing batch for key {Key}, creating new batch", batchKeyString); + } + } + + // Create new batch if not found or deserialization failed + return CreateNewBatch(batchKey); + } + + private MessageBatch CreateNewBatch(BatchKey key) + { + return new MessageBatch + { + HubName = key.HubName, + MethodName = key.MethodName, + ConnectionId = key.ConnectionId, + GroupName = key.GroupName, + CreatedAt = DateTime.UtcNow + }; + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Statistics.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Statistics.cs new file mode 100644 index 000000000..562f15dc7 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.Statistics.cs @@ -0,0 +1,172 @@ +using System.Text.Json; + +using StackExchange.Redis; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRMessageBatcher + { + private async Task InitializeStatisticsAsync() + { + if (_redis == null) return; + + try + { + var exists = await _redis.HashExistsAsync(_statisticsKey, "totalMessagesBatched"); + if (!exists) + { + var stats = new Dictionary + { + ["totalMessagesBatched"] = "0", + ["totalBatchesSent"] = "0", + ["totalBatchLatency"] = "0", + ["lastBatchSentAt"] = DateTime.UtcNow.ToBinary().ToString() + }; + + await _redis.HashSetAsync(_statisticsKey, stats.Select(kvp => new HashEntry(kvp.Key, kvp.Value)).ToArray()); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to initialize statistics in Redis"); + } + } + + public async Task GetStatisticsAsync() + { + if (_redis == null) + { + return new BatchingStatistics { IsBatchingEnabled = _isBatchingEnabled }; + } + + try + { + var globalStats = await _redis.HashGetAllAsync(_statisticsKey); + var methodStats = await _redis.HashGetAllAsync(_messagesByMethodKey); + var pendingMessages = await GetCurrentPendingMessagesAsync(); + + var stats = new BatchingStatistics + { + TotalMessagesBatched = GetLongValue(globalStats, "totalMessagesBatched"), + TotalBatchesSent = GetLongValue(globalStats, "totalBatchesSent"), + CurrentPendingMessages = pendingMessages, + LastBatchSentAt = GetDateTimeValue(globalStats, "lastBatchSentAt"), + IsBatchingEnabled = _isBatchingEnabled, + MessagesByMethod = methodStats.ToDictionary(kvp => kvp.Name.ToString(), kvp => (long)kvp.Value) + }; + + if (stats.TotalBatchesSent > 0) + { + stats.AverageMessagesPerBatch = (double)stats.TotalMessagesBatched / stats.TotalBatchesSent; + var totalBatchLatency = GetLongValue(globalStats, "totalBatchLatency"); + stats.AverageBatchLatency = TimeSpan.FromMilliseconds(totalBatchLatency / stats.TotalBatchesSent); + stats.NetworkCallsSaved = stats.TotalMessagesBatched - stats.TotalBatchesSent; + stats.BatchEfficiencyPercentage = (1.0 - ((double)stats.TotalBatchesSent / stats.TotalMessagesBatched)) * 100; + } + + return stats; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get statistics from Redis"); + return new BatchingStatistics { IsBatchingEnabled = _isBatchingEnabled }; + } + } + + private long GetLongValue(HashEntry[] hashEntries, string key) + { + var entry = hashEntries.FirstOrDefault(h => h.Name == key); + return entry.Value.HasValue && long.TryParse(entry.Value.ToString(), out var value) ? value : 0; + } + + private DateTime GetDateTimeValue(HashEntry[] hashEntries, string key) + { + var entry = hashEntries.FirstOrDefault(h => h.Name == key); + if (entry.Value.HasValue && long.TryParse(entry.Value.ToString(), out var binary)) + { + try + { + return DateTime.FromBinary(binary); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse timestamp from binary value, using current time"); + return DateTime.UtcNow; + } + } + return DateTime.UtcNow; + } + + private async Task GetCurrentPendingMessagesAsync() + { + try + { + var activeBatches = await _redis!.HashGetAllAsync(_activeBatchesKey); + long totalPending = 0; + + foreach (var batchData in activeBatches) + { + try + { + var batch = JsonSerializer.Deserialize(batchData.Value!.ToString()); + if (batch != null) + { + totalPending += batch.Messages.Count; + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize batch data for pending message count"); + } + } + + return totalPending; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get pending messages count"); + return 0; + } + } + + public async Task FlushAllBatchesAsync() + { + if (_redis == null) + { + _logger.LogWarning("Redis not available, cannot flush batches"); + return; + } + + _logger.LogInformation("Flushing all pending batches"); + + try + { + var activeBatches = await _redis.HashGetAllAsync(_activeBatchesKey); + var flushTasks = new List(); + + foreach (var batchEntry in activeBatches) + { + try + { + var batch = JsonSerializer.Deserialize(batchEntry.Value!.ToString()); + if (batch != null) + { + var batchKey = new BatchKey(batch.HubName, batch.MethodName, batch.ConnectionId, batch.GroupName); + flushTasks.Add(SendBatchAsync(batchKey, batch, batchEntry.Name!)); + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize batch data during flush for key {Key}", batchEntry.Name); + } + } + + await Task.WhenAll(flushTasks); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error flushing all batches"); + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.cs index 6cab737b9..1cd44f47d 100644 --- a/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.cs +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageBatcher.cs @@ -1,10 +1,10 @@ -using System.Collections.Concurrent; using System.Text.Json; +using System.Threading.Channels; using ConduitLLM.Configuration.Services; -using ConduitLLM.Gateway.Models; +using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Models.SignalR; -using Microsoft.AspNetCore.SignalR; using StackExchange.Redis; namespace ConduitLLM.Gateway.Services @@ -22,7 +22,7 @@ public interface ISignalRMessageBatcher /// /// Gets current batching statistics /// - BatchingStatistics GetStatistics(); + Task GetStatisticsAsync(); /// /// Forces immediate sending of all pending batches @@ -58,37 +58,45 @@ public class BatchingStatistics } /// - /// Implementation of SignalR message batcher + /// Implementation of SignalR message batcher. + /// Uses Channel-based signaling for batch processing to ensure proper error handling + /// and graceful shutdown instead of fire-and-forget Task.Run patterns. /// - public class SignalRMessageBatcher : ISignalRMessageBatcher, IHostedService, IDisposable + public partial class SignalRMessageBatcher : ISignalRMessageBatcher, IHostedService, IDisposable { private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly IServiceProvider _serviceProvider; private readonly RedisConnectionFactory _redisConnectionFactory; - + // Redis connection private IDatabase? _redis; - + // Redis keys private readonly string _activeBatchesKey; private readonly string _batchQueueKey; private readonly string _messagesByMethodKey; private readonly string _statisticsKey; - + // Synchronization private readonly SemaphoreSlim _batchProcessingLock; - + // Timers private Timer? _batchTimer; private readonly object _timerLock = new(); - + + // Channel-based signal processing - replaces fire-and-forget Task.Run + private enum BatchSignal { ProcessBatches, FlushAll } + private readonly Channel _signalChannel; + private Task? _signalProcessingTask; + private CancellationTokenSource? _shutdownCts; + // Configuration private readonly TimeSpan _batchWindow; private readonly int _maxBatchSize; private readonly long _maxBatchSizeBytes; private readonly bool _groupByMethod; - + // State private bool _isBatchingEnabled = true; private readonly object _stateLock = new(); @@ -105,10 +113,10 @@ public SignalRMessageBatcher( _redisConnectionFactory = redisConnectionFactory; // Redis keys - _activeBatchesKey = "signalr:batches:active"; - _batchQueueKey = "signalr:batches:queue"; - _messagesByMethodKey = "signalr:batches:stats:methods"; - _statisticsKey = "signalr:batches:stats:global"; + _activeBatchesKey = RedisKeys.SignalR.ActiveBatches; + _batchQueueKey = RedisKeys.SignalR.BatchQueue; + _messagesByMethodKey = RedisKeys.SignalR.BatchStatsMethods; + _statisticsKey = RedisKeys.SignalR.BatchStatsGlobal; // Load configuration _batchWindow = TimeSpan.FromMilliseconds(configuration.GetValue("SignalR:Batching:WindowMs", 100)); @@ -117,6 +125,14 @@ public SignalRMessageBatcher( _groupByMethod = configuration.GetValue("SignalR:Batching:GroupByMethod", true); _batchProcessingLock = new SemaphoreSlim(1, 1); + + // Bounded channel to prevent unbounded memory growth + _signalChannel = Channel.CreateBounded(new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); } public async Task StartAsync(CancellationToken cancellationToken) @@ -133,6 +149,10 @@ public async Task StartAsync(CancellationToken cancellationToken) // Initialize statistics in Redis if they don't exist await InitializeStatisticsAsync(); + // Start signal processing task for handling batch operations + _shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _signalProcessingTask = ProcessSignalsAsync(_shutdownCts.Token); + _batchTimer = new Timer( ProcessBatches, null, @@ -148,32 +168,6 @@ public async Task StartAsync(CancellationToken cancellationToken) } } - private async Task InitializeStatisticsAsync() - { - if (_redis == null) return; - - try - { - var exists = await _redis.HashExistsAsync(_statisticsKey, "totalMessagesBatched"); - if (!exists) - { - var stats = new Dictionary - { - ["totalMessagesBatched"] = "0", - ["totalBatchesSent"] = "0", - ["totalBatchLatency"] = "0", - ["lastBatchSentAt"] = DateTime.UtcNow.ToBinary().ToString() - }; - - await _redis.HashSetAsync(_statisticsKey, stats.Select(kvp => new HashEntry(kvp.Key, kvp.Value)).ToArray()); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to initialize statistics in Redis"); - } - } - public async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("SignalR Message Batcher stopping"); @@ -183,7 +177,10 @@ public async Task StopAsync(CancellationToken cancellationToken) _batchTimer?.Change(Timeout.Infinite, 0); } - // Flush remaining batches + // Complete the signal channel to stop accepting new signals + _signalChannel.Writer.Complete(); + + // Flush remaining batches directly (don't go through channel since it's completed) try { await FlushAllBatchesAsync().WaitAsync(TimeSpan.FromSeconds(5), cancellationToken); @@ -196,6 +193,24 @@ public async Task StopAsync(CancellationToken cancellationToken) { _logger.LogError(ex, "Error flushing batches during shutdown"); } + + // Wait for signal processing task to complete + if (_signalProcessingTask != null) + { + try + { + _shutdownCts?.Cancel(); + await _signalProcessingTask.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Signal processing task did not complete in time during shutdown"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error waiting for signal processing task during shutdown"); + } + } } public async Task AddMessageAsync( @@ -229,7 +244,7 @@ public async Task AddMessageAsync( var batch = await GetOrCreateBatchAsync(batchKeyString, batchKey); // Check if adding this message would exceed limits - if (batch.Messages.Count >= _maxBatchSize || + if (batch.Messages.Count >= _maxBatchSize || batch.TotalSizeBytes + messageSize > _maxBatchSizeBytes) { // Queue this batch for immediate sending @@ -237,9 +252,9 @@ public async Task AddMessageAsync( { batch.IsQueued = true; await _redis.ListRightPushAsync(_batchQueueKey, batchKeyString); - - // Trigger immediate processing - _ = Task.Run(async () => await ProcessBatchesAsync()); + + // Signal for immediate processing via channel (replaces Task.Run) + _signalChannel.Writer.TryWrite(BatchSignal.ProcessBatches); } // Create a new batch for this message @@ -264,7 +279,7 @@ public async Task AddMessageAsync( await _redis.HashIncrementAsync(_statisticsKey, "totalMessagesBatched"); await _redis.HashIncrementAsync(_messagesByMethodKey, methodName); - _logger.LogTrace( + _logger.LogDebug( "Added message to batch for {HubName}.{MethodName}, batch size: {Size}", hubName, methodName, batch.Messages.Count); } @@ -275,148 +290,6 @@ public async Task AddMessageAsync( } } - public BatchingStatistics GetStatistics() - { - // Synchronous wrapper for backward compatibility - return GetStatisticsAsync().GetAwaiter().GetResult(); - } - - public async Task GetStatisticsAsync() - { - if (_redis == null) - { - return new BatchingStatistics { IsBatchingEnabled = _isBatchingEnabled }; - } - - try - { - var globalStats = await _redis.HashGetAllAsync(_statisticsKey); - var methodStats = await _redis.HashGetAllAsync(_messagesByMethodKey); - var pendingMessages = await GetCurrentPendingMessagesAsync(); - - var stats = new BatchingStatistics - { - TotalMessagesBatched = GetLongValue(globalStats, "totalMessagesBatched"), - TotalBatchesSent = GetLongValue(globalStats, "totalBatchesSent"), - CurrentPendingMessages = pendingMessages, - LastBatchSentAt = GetDateTimeValue(globalStats, "lastBatchSentAt"), - IsBatchingEnabled = _isBatchingEnabled, - MessagesByMethod = methodStats.ToDictionary(kvp => kvp.Name.ToString(), kvp => (long)kvp.Value) - }; - - if (stats.TotalBatchesSent > 0) - { - stats.AverageMessagesPerBatch = (double)stats.TotalMessagesBatched / stats.TotalBatchesSent; - var totalBatchLatency = GetLongValue(globalStats, "totalBatchLatency"); - stats.AverageBatchLatency = TimeSpan.FromMilliseconds(totalBatchLatency / stats.TotalBatchesSent); - stats.NetworkCallsSaved = stats.TotalMessagesBatched - stats.TotalBatchesSent; - stats.BatchEfficiencyPercentage = (1.0 - ((double)stats.TotalBatchesSent / stats.TotalMessagesBatched)) * 100; - } - - return stats; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get statistics from Redis"); - return new BatchingStatistics { IsBatchingEnabled = _isBatchingEnabled }; - } - } - - private long GetLongValue(HashEntry[] hashEntries, string key) - { - var entry = hashEntries.FirstOrDefault(h => h.Name == key); - return entry.Value.HasValue && long.TryParse(entry.Value.ToString(), out var value) ? value : 0; - } - - private DateTime GetDateTimeValue(HashEntry[] hashEntries, string key) - { - var entry = hashEntries.FirstOrDefault(h => h.Name == key); - if (entry.Value.HasValue && long.TryParse(entry.Value.ToString(), out var binary)) - { - try - { - return DateTime.FromBinary(binary); - } - catch - { - return DateTime.UtcNow; - } - } - return DateTime.UtcNow; - } - - private async Task GetCurrentPendingMessagesAsync() - { - try - { - var activeBatches = await _redis!.HashGetAllAsync(_activeBatchesKey); - long totalPending = 0; - - foreach (var batchData in activeBatches) - { - try - { - var batch = JsonSerializer.Deserialize(batchData.Value!.ToString()); - if (batch != null) - { - totalPending += batch.Messages.Count; - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize batch data for pending message count"); - } - } - - return totalPending; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get pending messages count"); - return 0; - } - } - - public async Task FlushAllBatchesAsync() - { - if (_redis == null) - { - _logger.LogWarning("Redis not available, cannot flush batches"); - return; - } - - _logger.LogInformation("Flushing all pending batches"); - - try - { - var activeBatches = await _redis.HashGetAllAsync(_activeBatchesKey); - var flushTasks = new List(); - - foreach (var batchEntry in activeBatches) - { - try - { - var batch = JsonSerializer.Deserialize(batchEntry.Value!.ToString()); - if (batch != null) - { - var batchKey = new BatchKey(batch.HubName, batch.MethodName, batch.ConnectionId, batch.GroupName); - flushTasks.Add(SendBatchAsync(batchKey, batch, batchEntry.Name!)); - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize batch data during flush for key {Key}", batchEntry.Name); - } - } - - await Task.WhenAll(flushTasks); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error flushing all batches"); - } - } - public void PauseBatching() { lock (_stateLock) @@ -425,8 +298,8 @@ public void PauseBatching() _logger.LogInformation("Message batching paused"); } - // Flush pending batches - _ = Task.Run(async () => await FlushAllBatchesAsync()); + // Signal to flush pending batches via channel (replaces Task.Run) + _signalChannel.Writer.TryWrite(BatchSignal.FlushAll); } public void ResumeBatching() @@ -438,284 +311,11 @@ public void ResumeBatching() } } - private async void ProcessBatches(object? state) - { - await ProcessBatchesAsync(); - } - - private async Task ProcessBatchesAsync() - { - if (!await _batchProcessingLock.WaitAsync(0)) - { - // Already processing - return; - } - - try - { - var now = DateTime.UtcNow; - if (_redis == null) - { - return; - } - - var batchesToSend = new List<(BatchKey Key, MessageBatch Batch, string KeyString)>(); - - // Check all active batches - var activeBatches = await _redis.HashGetAllAsync(_activeBatchesKey); - foreach (var batchEntry in activeBatches) - { - try - { - var batch = JsonSerializer.Deserialize(batchEntry.Value!.ToString()); - if (batch != null && batch.Messages.Count > 0 && - (now - batch.CreatedAt >= _batchWindow || batch.IsQueued)) - { - var batchKey = new BatchKey(batch.HubName, batch.MethodName, batch.ConnectionId, batch.GroupName); - batchesToSend.Add((batchKey, batch, batchEntry.Name!)); - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize batch data for key {Key}", batchEntry.Name); - } - } - - // Send all ready batches - var sendTasks = batchesToSend.Select(item => SendBatchAsync(item.Key, item.Batch, item.KeyString)); - await Task.WhenAll(sendTasks); - - // Process explicitly queued batches - string? queuedBatchKey; - while ((queuedBatchKey = await _redis.ListLeftPopAsync(_batchQueueKey)) != null) - { - var batchData = await _redis.HashGetAsync(_activeBatchesKey, queuedBatchKey); - if (batchData.HasValue) - { - try - { - var batch = JsonSerializer.Deserialize(batchData.ToString()); - if (batch != null) - { - var batchKey = new BatchKey(batch.HubName, batch.MethodName, batch.ConnectionId, batch.GroupName); - await SendBatchAsync(batchKey, batch, queuedBatchKey); - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize queued batch data for key {Key}", queuedBatchKey); - } - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing message batches"); - } - finally - { - _batchProcessingLock.Release(); - } - } - - private async Task SendBatchAsync(BatchKey batchKey, MessageBatch batch, string batchKeyString) - { - if (_redis == null) - { - return; - } - - try - { - // Remove batch from active batches - var removed = await _redis.HashDeleteAsync(_activeBatchesKey, batchKeyString); - if (!removed) - { - return; // Batch already processed - } - - var batchLatency = DateTime.UtcNow - batch.CreatedAt; - - if (batch.Messages.Count == 0) - { - return; - } - - var messagesToSend = new List(batch.Messages); - var messageCount = messagesToSend.Count; - - try - { - using var scope = _serviceProvider.CreateScope(); - var hubContext = GetHubContext(scope, batch.HubName); - - if (hubContext == null) - { - _logger.LogError("Could not find hub context for {HubName}", batch.HubName); - return; - } - - // Create batched message - var batchedMessage = new BatchedMessage - { - Messages = messagesToSend, - MethodName = batch.MethodName, - HubName = batch.HubName, - ConnectionId = batch.ConnectionId, - GroupName = batch.GroupName, - TotalSizeBytes = batch.TotalSizeBytes, - Priority = batch.Priority, - ContainsCriticalMessages = batch.ContainsCriticalMessages - }; - - // Send based on target - if (!string.IsNullOrEmpty(batch.ConnectionId)) - { - await hubContext.Clients.Client(batch.ConnectionId) - .SendAsync($"{batch.MethodName}Batch", batchedMessage); - } - else if (!string.IsNullOrEmpty(batch.GroupName)) - { - await hubContext.Clients.Group(batch.GroupName) - .SendAsync($"{batch.MethodName}Batch", batchedMessage); - } - else - { - await hubContext.Clients.All - .SendAsync($"{batch.MethodName}Batch", batchedMessage); - } - - // Update statistics in Redis - await Task.WhenAll( - _redis.HashIncrementAsync(_statisticsKey, "totalBatchesSent"), - _redis.HashIncrementAsync(_statisticsKey, "totalBatchLatency", (long)batchLatency.TotalMilliseconds), - _redis.HashSetAsync(_statisticsKey, "lastBatchSentAt", DateTime.UtcNow.ToBinary().ToString()) - ); - - _logger.LogDebug( - "Sent batch of {Count} messages for {HubName}.{MethodName}, latency: {Latency}ms", - messageCount, batch.HubName, batch.MethodName, batchLatency.TotalMilliseconds); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error sending batch for {HubName}.{MethodName}", - batch.HubName, batch.MethodName); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in SendBatchAsync for key {Key}", batchKeyString); - } - } - - private async Task GetOrCreateBatchAsync(string batchKeyString, BatchKey batchKey) - { - var batchData = await _redis!.HashGetAsync(_activeBatchesKey, batchKeyString); - - if (batchData.HasValue) - { - try - { - var existingBatch = JsonSerializer.Deserialize(batchData.ToString()); - if (existingBatch != null) - { - return existingBatch; - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize existing batch for key {Key}, creating new batch", batchKeyString); - } - } - - // Create new batch if not found or deserialization failed - return CreateNewBatch(batchKey); - } - - private async Task SendMessageDirectlyAsync( - string hubName, - string methodName, - object message, - string? connectionId, - string? groupName) - { - try - { - using var scope = _serviceProvider.CreateScope(); - var hubContext = GetHubContext(scope, hubName); - - if (hubContext == null) - { - _logger.LogError("Could not find hub context for {HubName}", hubName); - return; - } - - if (!string.IsNullOrEmpty(connectionId)) - { - await hubContext.Clients.Client(connectionId).SendAsync(methodName, message); - } - else if (!string.IsNullOrEmpty(groupName)) - { - await hubContext.Clients.Group(groupName).SendAsync(methodName, message); - } - else - { - await hubContext.Clients.All.SendAsync(methodName, message); - } - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error sending message directly for {HubName}.{MethodName}", - hubName, methodName); - } - } - - private MessageBatch CreateNewBatch(BatchKey key) - { - return new MessageBatch - { - HubName = key.HubName, - MethodName = key.MethodName, - ConnectionId = key.ConnectionId, - GroupName = key.GroupName, - CreatedAt = DateTime.UtcNow - }; - } - - private long EstimateMessageSize(object message) - { - try - { - var json = JsonSerializer.Serialize(message); - return json.Length * sizeof(char); - } - catch - { - // Fallback estimate - return 1024; - } - } - - private IHubContext? GetHubContext(IServiceScope scope, string hubName) - { - var hubType = Type.GetType($"ConduitLLM.Gateway.Hubs.{hubName}, ConduitLLM.Gateway") ?? - Type.GetType($"ConduitLLM.Gateway.Hubs.{hubName}, ConduitLLM.Gateway"); - - if (hubType == null) - { - return null; - } - - var contextType = typeof(IHubContext<>).MakeGenericType(hubType); - return scope.ServiceProvider.GetService(contextType) as IHubContext; - } - public void Dispose() { _batchTimer?.Dispose(); _batchProcessingLock?.Dispose(); + _shutdownCts?.Dispose(); } /// diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.DeadLetter.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.DeadLetter.cs new file mode 100644 index 000000000..384a8cdad --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.DeadLetter.cs @@ -0,0 +1,201 @@ +using ConduitLLM.Gateway.Models; + +using Polly.CircuitBreaker; + +using StackExchange.Redis; +using System.Text.Json; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRMessageQueueService + { + public QueueStatistics GetStatistics() + { + if (_redis == null) + { + return new QueueStatistics + { + ProcessedMessages = _processedMessages, + FailedMessages = _failedMessages, + LastProcessedAt = _lastProcessedAt, + CircuitBreakerState = _currentCircuitState, + ConsecutiveFailures = _consecutiveFailures + }; + } + + try + { + var pendingMessages = _redis.StreamLength(_messageStreamKey); + var deadLetterMessages = _redis.StreamLength(_deadLetterStreamKey); + + return new QueueStatistics + { + PendingMessages = (int)pendingMessages, + DeadLetterMessages = (int)deadLetterMessages, + ProcessedMessages = _processedMessages, + FailedMessages = _failedMessages, + LastProcessedAt = _lastProcessedAt, + CircuitBreakerState = _currentCircuitState, + ConsecutiveFailures = _consecutiveFailures + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get queue statistics from Redis"); + return new QueueStatistics + { + ProcessedMessages = _processedMessages, + FailedMessages = _failedMessages, + LastProcessedAt = _lastProcessedAt, + CircuitBreakerState = _currentCircuitState, + ConsecutiveFailures = _consecutiveFailures + }; + } + } + + public IEnumerable GetDeadLetterMessages() + { + if (_redis == null) + { + return Enumerable.Empty(); + } + + try + { + var streamEntries = _redis.StreamRange(_deadLetterStreamKey, "-", "+", count: 100); + var messages = new List(); + + foreach (var entry in streamEntries) + { + try + { + var dataField = entry.Values.FirstOrDefault(v => v.Name == "data"); + if (dataField.Value.HasValue) + { + var message = JsonSerializer.Deserialize(dataField.Value!.ToString()); + if (message != null) + { + messages.Add(message); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize dead letter message from Redis stream entry {EntryId}", entry.Id); + } + } + + return messages; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get dead letter messages from Redis"); + return Enumerable.Empty(); + } + } + + public async Task RequeueDeadLetterAsync(string messageId) + { + if (_redis == null) + { + _logger.LogWarning("Redis not available, cannot requeue dead letter message: {MessageId}", messageId); + return; + } + + try + { + // Find the message in dead letter stream + var streamEntries = _redis.StreamRange(_deadLetterStreamKey, "-", "+"); + StreamEntry? targetEntry = null; + + foreach (var entry in streamEntries) + { + var messageIdField = entry.Values.FirstOrDefault(v => v.Name == "messageId"); + if (messageIdField.Value == messageId) + { + targetEntry = entry; + break; + } + } + + if (targetEntry.HasValue) + { + // Deserialize and modify the message + var dataField = targetEntry.Value.Values.FirstOrDefault(v => v.Name == "data"); + if (dataField.Value.HasValue) + { + var message = JsonSerializer.Deserialize(dataField.Value!.ToString()); + if (message != null) + { + message.IsDeadLetter = false; + message.DeadLetterReason = null; + message.DeliveryAttempts = 0; + message.LastError = null; + message.NextDeliveryAt = DateTime.UtcNow; + + // Re-enqueue to main stream + await EnqueueMessageAsync(message); + + // Remove from dead letter stream + await _redis.StreamDeleteAsync(_deadLetterStreamKey, new RedisValue[] { targetEntry.Value.Id }); + + _logger.LogInformation("Requeued dead letter message {MessageId}", messageId); + } + } + } + else + { + _logger.LogWarning("Dead letter message {MessageId} not found for requeue", messageId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to requeue dead letter message {MessageId}", messageId); + throw; + } + } + + private async Task MoveToDeadLetterAsync(QueuedMessage message, string reason, RedisValue? originalEntryId = null) + { + if (_redis == null) + { + _logger.LogWarning("Redis not available, cannot move message {MessageId} to dead letter", message.Message.MessageId); + return; + } + + try + { + message.IsDeadLetter = true; + message.DeadLetterReason = reason; + + var messageData = JsonSerializer.Serialize(message); + var streamFields = new NameValueEntry[] + { + new("data", messageData), + new("messageId", message.Message.MessageId), + new("hubName", message.HubName), + new("methodName", message.MethodName), + new("reason", reason), + new("deadLetteredAt", DateTime.UtcNow.ToString("O")) + }; + + await _redis.StreamAddAsync(_deadLetterStreamKey, streamFields); + + // If we have the original entry ID, acknowledge it from the main stream + if (originalEntryId.HasValue) + { + await _redis.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, originalEntryId.Value); + } + + _logger.LogWarning( + "Message {MessageId} moved to dead letter queue: {Reason}", + message.Message.MessageId, reason); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to move message {MessageId} to dead letter queue", message.Message.MessageId); + throw; + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.Delivery.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.Delivery.cs new file mode 100644 index 000000000..4ad185f58 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.Delivery.cs @@ -0,0 +1,142 @@ +using ConduitLLM.Gateway.Models; + +using Microsoft.AspNetCore.SignalR; + +using Polly; +using Polly.CircuitBreaker; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRMessageQueueService + { + private async Task ProcessSingleMessageAsync(QueuedMessage queuedMessage) + { + queuedMessage.DeliveryAttempts++; + queuedMessage.LastAttemptAt = DateTime.UtcNow; + + var context = new Context(); + context["message"] = queuedMessage; + + try + { + var result = await _circuitBreaker.ExecuteAsync(async (ctx) => + { + return await _retryPolicy.ExecuteAsync(async (retryCtx) => + { + return await DeliverMessageAsync(queuedMessage); + }, ctx); + }, context); + + if (result) + { + _processedMessages++; + _consecutiveFailures = 0; + _logger.LogInformation( + "Successfully delivered message {MessageId} after {Attempts} attempts", + queuedMessage.Message.MessageId, queuedMessage.DeliveryAttempts); + } + else + { + _failedMessages++; + _consecutiveFailures++; + } + + return result; + } + catch (BrokenCircuitException) + { + _logger.LogWarning( + "Circuit breaker is open, message {MessageId} delivery postponed", + queuedMessage.Message.MessageId); + queuedMessage.LastError = "Circuit breaker open"; + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Unexpected error delivering message {MessageId}", + queuedMessage.Message.MessageId); + queuedMessage.LastError = ex.Message; + _failedMessages++; + _consecutiveFailures++; + return false; + } + } + + private async Task DeliverMessageAsync(QueuedMessage queuedMessage) + { + using var scope = _serviceProvider.CreateScope(); + var hubContext = GetHubContext(scope, queuedMessage.HubName); + + if (hubContext == null) + { + _logger.LogError("Could not find hub context for {HubName}", queuedMessage.HubName); + queuedMessage.LastError = $"Hub {queuedMessage.HubName} not found"; + return false; + } + + try + { + // Update retry count + queuedMessage.Message.RetryCount = queuedMessage.DeliveryAttempts - 1; + + // Send the message + if (!string.IsNullOrEmpty(queuedMessage.ConnectionId)) + { + // Direct message to specific connection + await hubContext.Clients.Client(queuedMessage.ConnectionId) + .SendAsync(queuedMessage.MethodName, queuedMessage.Message); + } + else if (!string.IsNullOrEmpty(queuedMessage.GroupName)) + { + // Message to group + await hubContext.Clients.Group(queuedMessage.GroupName) + .SendAsync(queuedMessage.MethodName, queuedMessage.Message); + } + else + { + _logger.LogError("Message {MessageId} has no target connection or group", + queuedMessage.Message.MessageId); + return false; + } + + // Register for acknowledgment if it's a critical message + if (queuedMessage.Message.IsCritical) + { + var pending = await _acknowledgmentService.RegisterMessageAsync( + queuedMessage.Message, + queuedMessage.ConnectionId ?? "group-message", + queuedMessage.HubName, + queuedMessage.MethodName, + queuedMessage.AcknowledgmentTimeout); + + // Wait for acknowledgment + var acknowledged = await pending.CompletionSource.Task; + return acknowledged; + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error delivering message {MessageId} to {HubName}.{MethodName}", + queuedMessage.Message.MessageId, queuedMessage.HubName, queuedMessage.MethodName); + queuedMessage.LastError = ex.Message; + return false; + } + } + + private static IHubContext? GetHubContext(IServiceScope scope, string hubName) + => Utilities.SignalRHubContextResolver.Resolve(scope.ServiceProvider, hubName); + + private DateTime CalculateNextDeliveryTime(int attempts) + { + var delay = TimeSpan.FromSeconds(Math.Min( + _initialRetryDelay.TotalSeconds * Math.Pow(2, attempts), + _maxRetryDelay.TotalSeconds)); + + return DateTime.UtcNow.Add(delay); + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.Processing.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.Processing.cs new file mode 100644 index 000000000..7118bce9c --- /dev/null +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.Processing.cs @@ -0,0 +1,130 @@ +using ConduitLLM.Gateway.Models; + +using StackExchange.Redis; +using System.Text.Json; + +namespace ConduitLLM.Gateway.Services +{ + public partial class SignalRMessageQueueService + { + private void ProcessMessages(object? state) + { + // Fire-and-forget with proper exception handling - don't use async void + _ = ProcessMessagesAsync(); + } + + private async Task ProcessMessagesAsync() + { + if (_redis == null || _currentCircuitState == Polly.CircuitBreaker.CircuitState.Open) + { + if (_currentCircuitState == Polly.CircuitBreaker.CircuitState.Open) + { + _logger.LogDebug("Circuit breaker is open, skipping message processing"); + } + return; + } + + try + { + // Read pending messages from the consumer group + var streamEntries = await _redis.StreamReadGroupAsync( + _messageStreamKey, + _consumerGroup, + _consumerName, + ">", + count: _processingBatchSize); + + // TODO: Add pending message recovery in future iteration + // For now, focus on basic streaming functionality + + if (streamEntries.Length == 0) + { + return; + } + + _logger.LogDebug("Processing batch of {Count} messages from Redis stream", streamEntries.Length); + + // Process messages in parallel with limited concurrency + var tasks = streamEntries.Select(async entry => + { + await _processingLock.WaitAsync(); + try + { + await ProcessStreamEntry(entry); + } + finally + { + _processingLock.Release(); + } + }); + + await Task.WhenAll(tasks); + _lastProcessedAt = DateTime.UtcNow; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing messages from Redis stream"); + } + } + + private async Task ProcessStreamEntry(StreamEntry entry) + { + try + { + var dataField = entry.Values.FirstOrDefault(v => v.Name == "data"); + if (!dataField.Value.HasValue) + { + _logger.LogWarning("Stream entry {EntryId} missing data field", entry.Id); + await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); + return; + } + + var message = JsonSerializer.Deserialize(dataField.Value!.ToString()); + if (message == null) + { + _logger.LogWarning("Failed to deserialize message from stream entry {EntryId}", entry.Id); + await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); + return; + } + + // Check if message is ready for delivery + if (message.NextDeliveryAt > DateTime.UtcNow) + { + _logger.LogDebug("Message {MessageId} not ready for delivery, skipping", message.Message.MessageId); + return; // Don't acknowledge yet, will be picked up later + } + + // Check if message has expired + if (message.Message.IsExpired) + { + _logger.LogWarning("Message {MessageId} expired, moving to dead letter", message.Message.MessageId); + await MoveToDeadLetterAsync(message, "Message expired", entry.Id); + return; + } + + var success = await ProcessSingleMessageAsync(message); + if (!success && message.DeliveryAttempts >= _maxRetryAttempts) + { + await MoveToDeadLetterAsync(message, $"Failed after {_maxRetryAttempts} attempts", entry.Id); + } + else if (!success) + { + // Re-enqueue for retry with delay + message.NextDeliveryAt = CalculateNextDeliveryTime(message.DeliveryAttempts); + await EnqueueMessageAsync(message); + await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); + } + else + { + // Success - acknowledge the message + await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing stream entry {EntryId}", entry.Id); + // Don't acknowledge on error - message will be retried + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.cs b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.cs index b3452fb68..2d1bc1f51 100644 --- a/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.cs +++ b/Services/ConduitLLM.Gateway/Services/SignalRMessageQueueService.cs @@ -1,8 +1,7 @@ using ConduitLLM.Configuration.Services; +using ConduitLLM.Core.Constants; using ConduitLLM.Gateway.Models; -using Microsoft.AspNetCore.SignalR; - using Polly; using Polly.CircuitBreaker; @@ -54,7 +53,7 @@ public class QueueStatistics /// /// Implementation of SignalR message queue service using Redis Streams /// - public class SignalRMessageQueueService : ISignalRMessageQueueService, IHostedService, IDisposable + public partial class SignalRMessageQueueService : ISignalRMessageQueueService, IHostedService, IDisposable { private readonly ILogger _logger; private readonly IConfiguration _configuration; @@ -106,8 +105,8 @@ public SignalRMessageQueueService( // Redis keys var instanceId = Environment.MachineName; - _messageStreamKey = "signalr:messages"; - _deadLetterStreamKey = "signalr:deadletter"; + _messageStreamKey = RedisKeys.SignalR.MessageStream; + _deadLetterStreamKey = RedisKeys.SignalR.DeadLetterStream; _consumerGroup = "signalr-processors"; _consumerName = $"processor-{instanceId}-{Environment.ProcessId}"; @@ -208,23 +207,25 @@ public async Task StartAsync(CancellationToken cancellationToken) } } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("SignalR Message Queue Service stopping"); - + _processingTimer?.Change(Timeout.Infinite, 0); - // Wait for any in-flight processing to complete - try + // Wait for any in-flight processing to complete without blocking the shutdown + // thread. WaitAsync(timeout) returns false on timeout โ€” we proceed regardless. + if (_processingLock != null) { - _processingLock?.Wait(TimeSpan.FromSeconds(5)); - } - catch (ObjectDisposedException) - { - // Already disposed, ignore + try + { + await _processingLock.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } } - - return Task.CompletedTask; } public async Task EnqueueMessageAsync(QueuedMessage message) @@ -268,451 +269,6 @@ public async Task EnqueueMessageAsync(QueuedMessage message) } } - public QueueStatistics GetStatistics() - { - if (_redis == null) - { - return new QueueStatistics - { - ProcessedMessages = _processedMessages, - FailedMessages = _failedMessages, - LastProcessedAt = _lastProcessedAt, - CircuitBreakerState = _currentCircuitState, - ConsecutiveFailures = _consecutiveFailures - }; - } - - try - { - var pendingMessages = _redis.StreamLength(_messageStreamKey); - var deadLetterMessages = _redis.StreamLength(_deadLetterStreamKey); - - return new QueueStatistics - { - PendingMessages = (int)pendingMessages, - DeadLetterMessages = (int)deadLetterMessages, - ProcessedMessages = _processedMessages, - FailedMessages = _failedMessages, - LastProcessedAt = _lastProcessedAt, - CircuitBreakerState = _currentCircuitState, - ConsecutiveFailures = _consecutiveFailures - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get queue statistics from Redis"); - return new QueueStatistics - { - ProcessedMessages = _processedMessages, - FailedMessages = _failedMessages, - LastProcessedAt = _lastProcessedAt, - CircuitBreakerState = _currentCircuitState, - ConsecutiveFailures = _consecutiveFailures - }; - } - } - - public IEnumerable GetDeadLetterMessages() - { - if (_redis == null) - { - return Enumerable.Empty(); - } - - try - { - var streamEntries = _redis.StreamRange(_deadLetterStreamKey, "-", "+", count: 100); - var messages = new List(); - - foreach (var entry in streamEntries) - { - try - { - var dataField = entry.Values.FirstOrDefault(v => v.Name == "data"); - if (dataField.Value.HasValue) - { - var message = JsonSerializer.Deserialize(dataField.Value!.ToString()); - if (message != null) - { - messages.Add(message); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to deserialize dead letter message from Redis stream entry {EntryId}", entry.Id); - } - } - - return messages; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to get dead letter messages from Redis"); - return Enumerable.Empty(); - } - } - - public async Task RequeueDeadLetterAsync(string messageId) - { - if (_redis == null) - { - _logger.LogWarning("Redis not available, cannot requeue dead letter message: {MessageId}", messageId); - return; - } - - try - { - // Find the message in dead letter stream - var streamEntries = _redis.StreamRange(_deadLetterStreamKey, "-", "+"); - StreamEntry? targetEntry = null; - - foreach (var entry in streamEntries) - { - var messageIdField = entry.Values.FirstOrDefault(v => v.Name == "messageId"); - if (messageIdField.Value == messageId) - { - targetEntry = entry; - break; - } - } - - if (targetEntry.HasValue) - { - // Deserialize and modify the message - var dataField = targetEntry.Value.Values.FirstOrDefault(v => v.Name == "data"); - if (dataField.Value.HasValue) - { - var message = JsonSerializer.Deserialize(dataField.Value!.ToString()); - if (message != null) - { - message.IsDeadLetter = false; - message.DeadLetterReason = null; - message.DeliveryAttempts = 0; - message.LastError = null; - message.NextDeliveryAt = DateTime.UtcNow; - - // Re-enqueue to main stream - await EnqueueMessageAsync(message); - - // Remove from dead letter stream - await _redis.StreamDeleteAsync(_deadLetterStreamKey, new RedisValue[] { targetEntry.Value.Id }); - - _logger.LogInformation("Requeued dead letter message {MessageId}", messageId); - } - } - } - else - { - _logger.LogWarning("Dead letter message {MessageId} not found for requeue", messageId); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to requeue dead letter message {MessageId}", messageId); - throw; - } - } - - private async void ProcessMessages(object? state) - { - if (_redis == null || _currentCircuitState == CircuitState.Open) - { - if (_currentCircuitState == CircuitState.Open) - { - _logger.LogDebug("Circuit breaker is open, skipping message processing"); - } - return; - } - - try - { - // Read pending messages from the consumer group - var streamEntries = await _redis.StreamReadGroupAsync( - _messageStreamKey, - _consumerGroup, - _consumerName, - ">", - count: _processingBatchSize); - - // TODO: Add pending message recovery in future iteration - // For now, focus on basic streaming functionality - - if (streamEntries.Length == 0) - { - return; - } - - _logger.LogDebug("Processing batch of {Count} messages from Redis stream", streamEntries.Length); - - // Process messages in parallel with limited concurrency - var tasks = streamEntries.Select(async entry => - { - await _processingLock.WaitAsync(); - try - { - await ProcessStreamEntry(entry); - } - finally - { - _processingLock.Release(); - } - }); - - await Task.WhenAll(tasks); - _lastProcessedAt = DateTime.UtcNow; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing messages from Redis stream"); - } - } - - private async Task ProcessStreamEntry(StreamEntry entry) - { - try - { - var dataField = entry.Values.FirstOrDefault(v => v.Name == "data"); - if (!dataField.Value.HasValue) - { - _logger.LogWarning("Stream entry {EntryId} missing data field", entry.Id); - await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); - return; - } - - var message = JsonSerializer.Deserialize(dataField.Value!.ToString()); - if (message == null) - { - _logger.LogWarning("Failed to deserialize message from stream entry {EntryId}", entry.Id); - await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); - return; - } - - // Check if message is ready for delivery - if (message.NextDeliveryAt > DateTime.UtcNow) - { - _logger.LogDebug("Message {MessageId} not ready for delivery, skipping", message.Message.MessageId); - return; // Don't acknowledge yet, will be picked up later - } - - // Check if message has expired - if (message.Message.IsExpired) - { - _logger.LogWarning("Message {MessageId} expired, moving to dead letter", message.Message.MessageId); - await MoveToDeadLetterAsync(message, "Message expired", entry.Id); - return; - } - - var success = await ProcessSingleMessageAsync(message); - if (!success && message.DeliveryAttempts >= _maxRetryAttempts) - { - await MoveToDeadLetterAsync(message, $"Failed after {_maxRetryAttempts} attempts", entry.Id); - } - else if (!success) - { - // Re-enqueue for retry with delay - message.NextDeliveryAt = CalculateNextDeliveryTime(message.DeliveryAttempts); - await EnqueueMessageAsync(message); - await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); - } - else - { - // Success - acknowledge the message - await _redis!.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, entry.Id); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing stream entry {EntryId}", entry.Id); - // Don't acknowledge on error - message will be retried - } - } - - private async Task ProcessSingleMessageAsync(QueuedMessage queuedMessage) - { - queuedMessage.DeliveryAttempts++; - queuedMessage.LastAttemptAt = DateTime.UtcNow; - - var context = new Context(); - context["message"] = queuedMessage; - - try - { - var result = await _circuitBreaker.ExecuteAsync(async (ctx) => - { - return await _retryPolicy.ExecuteAsync(async (retryCtx) => - { - return await DeliverMessageAsync(queuedMessage); - }, ctx); - }, context); - - if (result) - { - _processedMessages++; - _consecutiveFailures = 0; - _logger.LogDebug( - "Successfully delivered message {MessageId} after {Attempts} attempts", - queuedMessage.Message.MessageId, queuedMessage.DeliveryAttempts); - } - else - { - _failedMessages++; - _consecutiveFailures++; - } - - return result; - } - catch (BrokenCircuitException) - { - _logger.LogWarning( - "Circuit breaker is open, message {MessageId} delivery postponed", - queuedMessage.Message.MessageId); - queuedMessage.LastError = "Circuit breaker open"; - return false; - } - catch (Exception ex) - { - _logger.LogError(ex, - "Unexpected error delivering message {MessageId}", - queuedMessage.Message.MessageId); - queuedMessage.LastError = ex.Message; - _failedMessages++; - _consecutiveFailures++; - return false; - } - } - - private async Task DeliverMessageAsync(QueuedMessage queuedMessage) - { - using var scope = _serviceProvider.CreateScope(); - var hubContext = GetHubContext(scope, queuedMessage.HubName); - - if (hubContext == null) - { - _logger.LogError("Could not find hub context for {HubName}", queuedMessage.HubName); - queuedMessage.LastError = $"Hub {queuedMessage.HubName} not found"; - return false; - } - - try - { - // Update retry count - queuedMessage.Message.RetryCount = queuedMessage.DeliveryAttempts - 1; - - // Send the message - if (!string.IsNullOrEmpty(queuedMessage.ConnectionId)) - { - // Direct message to specific connection - await hubContext.Clients.Client(queuedMessage.ConnectionId) - .SendAsync(queuedMessage.MethodName, queuedMessage.Message); - } - else if (!string.IsNullOrEmpty(queuedMessage.GroupName)) - { - // Message to group - await hubContext.Clients.Group(queuedMessage.GroupName) - .SendAsync(queuedMessage.MethodName, queuedMessage.Message); - } - else - { - _logger.LogError("Message {MessageId} has no target connection or group", - queuedMessage.Message.MessageId); - return false; - } - - // Register for acknowledgment if it's a critical message - if (queuedMessage.Message.IsCritical) - { - var pending = await _acknowledgmentService.RegisterMessageAsync( - queuedMessage.Message, - queuedMessage.ConnectionId ?? "group-message", - queuedMessage.HubName, - queuedMessage.MethodName, - queuedMessage.AcknowledgmentTimeout); - - // Wait for acknowledgment - var acknowledged = await pending.CompletionSource.Task; - return acknowledged; - } - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error delivering message {MessageId} to {HubName}.{MethodName}", - queuedMessage.Message.MessageId, queuedMessage.HubName, queuedMessage.MethodName); - queuedMessage.LastError = ex.Message; - return false; - } - } - - private IHubContext? GetHubContext(IServiceScope scope, string hubName) - { - // This is a simplified version - in production, you'd want a more robust hub resolution mechanism - var hubType = Type.GetType($"ConduitLLM.Gateway.Hubs.{hubName}, ConduitLLM.Gateway") ?? - Type.GetType($"ConduitLLM.Gateway.Hubs.{hubName}, ConduitLLM.Gateway"); - - if (hubType == null) - { - return null; - } - - var contextType = typeof(IHubContext<>).MakeGenericType(hubType); - return scope.ServiceProvider.GetService(contextType) as IHubContext; - } - - private DateTime CalculateNextDeliveryTime(int attempts) - { - var delay = TimeSpan.FromSeconds(Math.Min( - _initialRetryDelay.TotalSeconds * Math.Pow(2, attempts), - _maxRetryDelay.TotalSeconds)); - - return DateTime.UtcNow.Add(delay); - } - - private async Task MoveToDeadLetterAsync(QueuedMessage message, string reason, RedisValue? originalEntryId = null) - { - if (_redis == null) - { - _logger.LogWarning("Redis not available, cannot move message {MessageId} to dead letter", message.Message.MessageId); - return; - } - - try - { - message.IsDeadLetter = true; - message.DeadLetterReason = reason; - - var messageData = JsonSerializer.Serialize(message); - var streamFields = new NameValueEntry[] - { - new("data", messageData), - new("messageId", message.Message.MessageId), - new("hubName", message.HubName), - new("methodName", message.MethodName), - new("reason", reason), - new("deadLetteredAt", DateTime.UtcNow.ToString("O")) - }; - - await _redis.StreamAddAsync(_deadLetterStreamKey, streamFields); - - // If we have the original entry ID, acknowledge it from the main stream - if (originalEntryId.HasValue) - { - await _redis.StreamAcknowledgeAsync(_messageStreamKey, _consumerGroup, originalEntryId.Value); - } - - _logger.LogWarning( - "Message {MessageId} moved to dead letter queue: {Reason}", - message.Message.MessageId, reason); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to move message {MessageId} to dead letter queue", message.Message.MessageId); - throw; - } - } - public void Dispose() { _processingTimer?.Dispose(); diff --git a/Services/ConduitLLM.Gateway/Services/SignalRMetricsService.cs b/Services/ConduitLLM.Gateway/Services/SignalRMetricsService.cs deleted file mode 100644 index 6a090fbc0..000000000 --- a/Services/ConduitLLM.Gateway/Services/SignalRMetricsService.cs +++ /dev/null @@ -1,306 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Options; -using Prometheus; -using ConduitLLM.Configuration.Options; - -namespace ConduitLLM.Gateway.Services -{ - /// - /// Service for tracking SignalR connection metrics and hub activity. - /// Critical for monitoring real-time communication at 10K scale. - /// - public class SignalRMetricsService : IHostedService, IDisposable - { - private readonly ILogger _logger; - private readonly ConcurrentDictionary _activeConnections; - private readonly SignalRConnectionOptions _connectionOptions; - private Timer? _metricsTimer; - - // Connection tracking - private class ConnectionInfo - { - public string ConnectionId { get; set; } = string.Empty; - public string HubName { get; set; } = string.Empty; - public string VirtualKeyId { get; set; } = string.Empty; - public DateTime ConnectedAt { get; set; } - public DateTime LastActivity { get; set; } - } - - // Prometheus metrics - private static readonly Gauge ActiveConnections = Prometheus.Metrics - .CreateGauge("conduit_signalr_connections_active", "Number of active SignalR connections", - new GaugeConfiguration - { - LabelNames = new[] { "hub", "virtual_key_id" } - }); - - private static readonly Counter ConnectionsTotal = Prometheus.Metrics - .CreateCounter("conduit_signalr_connections_total", "Total number of SignalR connections", - new CounterConfiguration - { - LabelNames = new[] { "hub", "status" } // status: connected, disconnected, failed - }); - - private static readonly Histogram ConnectionDuration = Prometheus.Metrics - .CreateHistogram("conduit_signalr_connection_duration_seconds", "SignalR connection duration in seconds", - new HistogramConfiguration - { - LabelNames = new[] { "hub" }, - Buckets = Histogram.ExponentialBuckets(1, 2, 16) // 1s to ~18 hours - }); - - private static readonly Counter MessagesTotal = Prometheus.Metrics - .CreateCounter("conduit_signalr_messages_total", "Total number of SignalR messages", - new CounterConfiguration - { - LabelNames = new[] { "hub", "method", "direction" } // direction: sent, received - }); - - private static readonly Counter SubscriptionsTotal = Prometheus.Metrics - .CreateCounter("conduit_signalr_subscriptions_total", "Total number of task subscriptions", - new CounterConfiguration - { - LabelNames = new[] { "hub", "task_type" } // task_type: image, video - }); - - private static readonly Gauge ActiveSubscriptions = Prometheus.Metrics - .CreateGauge("conduit_signalr_subscriptions_active", "Number of active task subscriptions", - new GaugeConfiguration - { - LabelNames = new[] { "hub", "task_type" } - }); - - private static readonly Counter ReconnectionsTotal = Prometheus.Metrics - .CreateCounter("conduit_signalr_reconnections_total", "Total number of SignalR reconnections", - new CounterConfiguration - { - LabelNames = new[] { "hub" } - }); - - private static readonly Summary MessageProcessingTime = Prometheus.Metrics - .CreateSummary("conduit_signalr_message_processing_seconds", "SignalR message processing time", - new SummaryConfiguration - { - LabelNames = new[] { "hub", "method" }, - Objectives = new[] - { - new QuantileEpsilonPair(0.5, 0.05), - new QuantileEpsilonPair(0.9, 0.01), - new QuantileEpsilonPair(0.95, 0.005), - new QuantileEpsilonPair(0.99, 0.001) - }, - MaxAge = TimeSpan.FromMinutes(5), - AgeBuckets = 5 - }); - - private static readonly Gauge ConnectionPoolUtilization = Prometheus.Metrics - .CreateGauge("conduit_signalr_connection_pool_utilization", "SignalR connection pool utilization percentage", - new GaugeConfiguration - { - LabelNames = new[] { "hub" } - }); - - public SignalRMetricsService( - ILogger logger, - IOptions connectionOptions) - { - _logger = logger; - _activeConnections = new ConcurrentDictionary(); - _connectionOptions = connectionOptions?.Value ?? new SignalRConnectionOptions(); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("SignalR metrics service starting..."); - - // Start periodic metrics calculation (every 30 seconds) - _metricsTimer = new Timer(CalculateMetrics, null, TimeSpan.Zero, TimeSpan.FromSeconds(30)); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("SignalR metrics service stopping..."); - - _metricsTimer?.Change(Timeout.Infinite, 0); - - return Task.CompletedTask; - } - - public void Dispose() - { - _metricsTimer?.Dispose(); - } - - /// - /// Track a new SignalR connection - /// - public void OnConnected(string connectionId, string hubName, string virtualKeyId) - { - var info = new ConnectionInfo - { - ConnectionId = connectionId, - HubName = hubName, - VirtualKeyId = virtualKeyId, - ConnectedAt = DateTime.UtcNow, - LastActivity = DateTime.UtcNow - }; - - if (_activeConnections.TryAdd(connectionId, info)) - { - ConnectionsTotal.WithLabels(hubName, "connected").Inc(); - ActiveConnections.WithLabels(hubName, virtualKeyId).Inc(); - - _logger.LogDebug("SignalR connection {ConnectionId} connected to hub {HubName}", connectionId, hubName); - } - } - - /// - /// Track a SignalR disconnection - /// - public void OnDisconnected(string connectionId, string? exception = null) - { - if (_activeConnections.TryRemove(connectionId, out var info)) - { - var duration = (DateTime.UtcNow - info.ConnectedAt).TotalSeconds; - var status = string.IsNullOrEmpty(exception) ? "disconnected" : "failed"; - - ConnectionsTotal.WithLabels(info.HubName, status).Inc(); - ActiveConnections.WithLabels(info.HubName, info.VirtualKeyId).Dec(); - ConnectionDuration.WithLabels(info.HubName).Observe(duration); - - _logger.LogDebug("SignalR connection {ConnectionId} disconnected from hub {HubName} after {Duration:F2}s", - connectionId, info.HubName, duration); - } - } - - /// - /// Track a reconnection - /// - public void OnReconnected(string connectionId, string hubName) - { - ReconnectionsTotal.WithLabels(hubName).Inc(); - - if (_activeConnections.TryGetValue(connectionId, out var info)) - { - info.LastActivity = DateTime.UtcNow; - } - } - - /// - /// Track message sent to client - /// - public void OnMessageSent(string hubName, string method, double processingTimeMs = 0) - { - MessagesTotal.WithLabels(hubName, method, "sent").Inc(); - - if (processingTimeMs > 0) - { - MessageProcessingTime.WithLabels(hubName, method).Observe(processingTimeMs / 1000.0); - } - } - - /// - /// Track message received from client - /// - public void OnMessageReceived(string hubName, string method) - { - MessagesTotal.WithLabels(hubName, method, "received").Inc(); - } - - /// - /// Track task subscription - /// - public void OnTaskSubscribed(string hubName, string taskType) - { - SubscriptionsTotal.WithLabels(hubName, taskType).Inc(); - ActiveSubscriptions.WithLabels(hubName, taskType).Inc(); - } - - /// - /// Track task unsubscription - /// - public void OnTaskUnsubscribed(string hubName, string taskType) - { - ActiveSubscriptions.WithLabels(hubName, taskType).Dec(); - } - - /// - /// Get connection count for a virtual key - /// - public int GetConnectionCountForVirtualKey(string virtualKeyId) - { - var count = 0; - foreach (var connection in _activeConnections.Values) - { - if (connection.VirtualKeyId == virtualKeyId) - count++; - } - return count; - } - - /// - /// Check if virtual key has reached connection limit - /// - public bool IsConnectionLimitReached(string virtualKeyId) - { - return GetConnectionCountForVirtualKey(virtualKeyId) >= _connectionOptions.MaxConnectionsPerVirtualKey; - } - - /// - /// Check if global connection limit is reached - /// - public bool IsGlobalConnectionLimitReached() - { - return _activeConnections.Count >= _connectionOptions.MaxTotalConnections; - } - - private void CalculateMetrics(object? state) - { - try - { - // Calculate connection pool utilization per hub - var hubConnections = new Dictionary(); - foreach (var connection in _activeConnections.Values) - { - if (!hubConnections.ContainsKey(connection.HubName)) - hubConnections[connection.HubName] = 0; - hubConnections[connection.HubName]++; - } - - // Update pool utilization metrics - foreach (var (hub, count) in hubConnections) - { - var utilization = (double)count / _connectionOptions.MaxTotalConnections * 100; - ConnectionPoolUtilization.WithLabels(hub).Set(utilization); - } - - // Clean up stale connections (no activity for 5 minutes) - var staleThreshold = DateTime.UtcNow.AddMinutes(-5); - var staleConnections = _activeConnections - .Where(kvp => kvp.Value.LastActivity < staleThreshold) - .Select(kvp => kvp.Key) - .ToList(); - - foreach (var connectionId in staleConnections) - { - _logger.LogWarning("Removing stale SignalR connection {ConnectionId}", connectionId); - OnDisconnected(connectionId, "Stale connection removed"); - } - - // Log warning if approaching limits - var totalConnections = _activeConnections.Count; - if (totalConnections > _connectionOptions.MaxTotalConnections * 0.8) - { - _logger.LogWarning("SignalR connections approaching limit: {Count}/{Max} ({Percentage:F1}%)", - totalConnections, _connectionOptions.MaxTotalConnections, (double)totalConnections / _connectionOptions.MaxTotalConnections * 100); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error calculating SignalR metrics"); - } - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/SignalROpenTelemetryService.cs b/Services/ConduitLLM.Gateway/Services/SignalROpenTelemetryService.cs index a1f568079..579984093 100644 --- a/Services/ConduitLLM.Gateway/Services/SignalROpenTelemetryService.cs +++ b/Services/ConduitLLM.Gateway/Services/SignalROpenTelemetryService.cs @@ -38,28 +38,40 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } private void CollectMetrics(object? state) + { + // Fire-and-forget async metrics collection with proper exception handling + _ = CollectMetricsAsync(); + } + + private async Task CollectMetricsAsync() { try { using var scope = _serviceProvider.CreateScope(); - + + int totalConnections = 0; + int pendingMessages = 0; + int deadLetterMessages = 0; + // Collect connection metrics var connectionMonitor = scope.ServiceProvider.GetService(); if (connectionMonitor != null) { - var stats = connectionMonitor.GetStatistics(); - + var stats = await connectionMonitor.GetStatisticsAsync(); + // Update gauge metrics foreach (var hub in stats.ConnectionsByHub) { _metrics.UpdateActiveConnections(hub.Key, 0); // Reset to current value + totalConnections += hub.Value; } - + // Record acknowledgment rate if (stats.TotalMessagesSent > 0) { var ackRate = (double)stats.TotalMessagesAcknowledged / stats.TotalMessagesSent * 100; - _logger.LogDebug("Acknowledgment rate: {Rate}%", ackRate); + _logger.LogDebug("SignalR acknowledgment rate: {Rate:F1}%, messages sent: {Sent}, acknowledged: {Acked}", + ackRate, stats.TotalMessagesSent, stats.TotalMessagesAcknowledged); } } @@ -68,22 +80,33 @@ private void CollectMetrics(object? state) if (queueService != null) { var stats = queueService.GetStatistics(); - _metrics.UpdateQueueDepth(stats.PendingMessages); - _metrics.UpdateDeadLetterQueueDepth(stats.DeadLetterMessages); + pendingMessages = stats.PendingMessages; + deadLetterMessages = stats.DeadLetterMessages; + _metrics.UpdateQueueDepth(pendingMessages); + _metrics.UpdateDeadLetterQueueDepth(deadLetterMessages); } // Collect batching metrics var batchingService = scope.ServiceProvider.GetService(); if (batchingService != null) { - var stats = batchingService.GetStatistics(); + var stats = await batchingService.GetStatisticsAsync(); _metrics.UpdatePendingBatches((int)stats.CurrentPendingMessages); - + if (stats.BatchEfficiencyPercentage > 0) { - _logger.LogDebug("Batch efficiency: {Efficiency}%", stats.BatchEfficiencyPercentage); + _logger.LogDebug("SignalR batch efficiency: {Efficiency:F1}%", stats.BatchEfficiencyPercentage); } } + + _logger.LogDebug( + "SignalR metrics collection completed โ€” connections: {Connections}, pending: {Pending}, dead letters: {DeadLetters}", + totalConnections, pendingMessages, deadLetterMessages); + + if (deadLetterMessages > 0) + { + _logger.LogWarning("SignalR dead letter queue has {DeadLetterCount} messages", deadLetterMessages); + } } catch (Exception ex) { @@ -120,38 +143,8 @@ public static IServiceCollection AddSignalRMetrics(this IServiceCollection servi { services.AddSingleton(); services.AddHostedService(); - - return services; - } - /// - /// Records a SignalR operation with metrics - /// - public static async Task RecordSignalROperationAsync( - this SignalRMetrics metrics, - string hub, - string method, - Func> operation) - { - using var activity = SignalRMetrics.StartMessageActivity($"SignalR.{method}", hub, method); - var startTime = DateTime.UtcNow; - - try - { - var result = await operation(); - - var duration = (DateTime.UtcNow - startTime).TotalMilliseconds; - metrics.RecordMessageDeliveryDuration(hub, method, duration); - metrics.RecordMessageDelivered(hub, method, true); - - return result; - } - catch (Exception ex) - { - metrics.RecordMessageDelivered(hub, method, false); - activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, ex.Message); - throw; - } + return services; } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/SlackNotificationChannel.cs b/Services/ConduitLLM.Gateway/Services/SlackNotificationChannel.cs index 74f87eea2..87a6c41dd 100644 --- a/Services/ConduitLLM.Gateway/Services/SlackNotificationChannel.cs +++ b/Services/ConduitLLM.Gateway/Services/SlackNotificationChannel.cs @@ -54,7 +54,7 @@ public async Task SendAsync(HealthAlert alert, CancellationToken cancellationTok }; // Add suggested actions if any - if (alert.SuggestedActions.Count() > 0) + if (alert.SuggestedActions.Any()) { attachments.Add(new { @@ -152,8 +152,8 @@ private async Task SendToSlackAsync(object payload, CancellationToken cancellati var json = JsonSerializer.Serialize(payload); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync(_options.WebhookUrl, content, cancellationToken); - + using var response = await httpClient.PostAsync(_options.WebhookUrl, content, cancellationToken); + if (!response.IsSuccessStatusCode) { var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/Services/ConduitLLM.Gateway/Services/SpendNotification/BudgetAlertManager.cs b/Services/ConduitLLM.Gateway/Services/SpendNotification/BudgetAlertManager.cs index aa8e6ca7b..7a3eea23d 100644 --- a/Services/ConduitLLM.Gateway/Services/SpendNotification/BudgetAlertManager.cs +++ b/Services/ConduitLLM.Gateway/Services/SpendNotification/BudgetAlertManager.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.SignalR; using ConduitLLM.Configuration.DTOs.SignalR; +using ConduitLLM.Core.Constants; using ConduitLLM.Core.Interfaces; using ConduitLLM.Gateway.Hubs; @@ -75,7 +76,7 @@ public async Task CheckBudgetThresholdsAsync( private async Task ProcessThresholdAlertAsync( int virtualKeyId, int threshold, decimal totalSpend, decimal budget, decimal percentageUsed) { - var lockKey = $"lock:alert:vk:{virtualKeyId}:threshold:{threshold}"; + var lockKey = RedisKeys.Lock.AlertThreshold(virtualKeyId.ToString(), threshold.ToString()); // Try to acquire distributed lock using var lockHandle = await _lockService.AcquireLockAsync( diff --git a/Services/ConduitLLM.Gateway/Services/SpendNotification/SpendDataRepository.cs b/Services/ConduitLLM.Gateway/Services/SpendNotification/SpendDataRepository.cs index eeaaa2e39..31887370c 100644 --- a/Services/ConduitLLM.Gateway/Services/SpendNotification/SpendDataRepository.cs +++ b/Services/ConduitLLM.Gateway/Services/SpendNotification/SpendDataRepository.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ConduitLLM.Core.Constants; using StackExchange.Redis; namespace ConduitLLM.Gateway.Services.SpendNotification @@ -81,12 +82,6 @@ public class SpendDataRepository : ISpendDataRepository private readonly IDatabase _database; private readonly ILogger _logger; - // Redis keys - private const string SpendingPatternsKey = "spend:patterns"; - private const string SentAlertsKeyPrefix = "spend:alerts:sent"; - private const string AlertCooldownKeyPrefix = "spend:alerts:cooldown"; - private const string SpendHistoryStreamKey = "spend:history:stream"; - private const string InstancesSetKey = "spend:notification:instances"; private readonly TimeSpan _patternRetentionPeriod = TimeSpan.FromHours(24); @@ -100,7 +95,7 @@ public async Task RecordSpendingPatternAsync(int virtualKeyId, decimal amount, d { try { - var patternKey = $"{SpendingPatternsKey}:{virtualKeyId}"; + var patternKey = RedisKeys.Spend.Patterns(virtualKeyId.ToString()); var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Get existing pattern data @@ -131,7 +126,7 @@ public async Task RecordSpendingPatternAsync(int virtualKeyId, decimal amount, d new("timestamp", now.ToString()) }; - await _database.StreamAddAsync(SpendHistoryStreamKey, streamEntry, maxLength: 10000); + await _database.StreamAddAsync(RedisKeys.Spend.HistoryStream, streamEntry, maxLength: 10000); } catch (Exception ex) { @@ -143,7 +138,7 @@ public async Task RecordSpendingPatternAsync(int virtualKeyId, decimal amount, d { try { - var patternKey = $"{SpendingPatternsKey}:{virtualKeyId}"; + var patternKey = RedisKeys.Spend.Patterns(virtualKeyId.ToString()); var patternData = await _database.HashGetAllAsync(patternKey); if (patternData.Length == 0) return null; @@ -172,7 +167,7 @@ public Task> GetAllPatternKeysAsync() try { var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); - var patternKeys = server.Keys(pattern: $"{SpendingPatternsKey}:*").ToArray(); + var patternKeys = server.Keys(pattern: RedisKeys.Spend.PatternsScanPattern()).ToArray(); foreach (var key in patternKeys) { @@ -192,13 +187,13 @@ public Task> GetAllPatternKeysAsync() public async Task IsAlertSentAsync(int virtualKeyId, int threshold) { - var alertKey = $"{SentAlertsKeyPrefix}:{virtualKeyId}:{threshold}"; + var alertKey = RedisKeys.Spend.SentAlert(virtualKeyId.ToString(), threshold.ToString()); return await _database.KeyExistsAsync(alertKey); } public async Task MarkAlertSentAsync(int virtualKeyId, int threshold, TimeSpan ttl) { - var alertKey = $"{SentAlertsKeyPrefix}:{virtualKeyId}:{threshold}"; + var alertKey = RedisKeys.Spend.SentAlert(virtualKeyId.ToString(), threshold.ToString()); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); var result = await _database.StringSetAsync(alertKey, timestamp, when: When.NotExists); @@ -212,14 +207,14 @@ public async Task MarkAlertSentAsync(int virtualKeyId, int threshold, Time public async Task SetAlertCooldownAsync(int virtualKeyId, string alertType, TimeSpan cooldown) { - var cooldownKey = $"{AlertCooldownKeyPrefix}:{virtualKeyId}:{alertType}"; + var cooldownKey = RedisKeys.Spend.Cooldown(virtualKeyId.ToString(), alertType); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); await _database.StringSetAsync(cooldownKey, timestamp, cooldown); } public async Task IsAlertInCooldownAsync(int virtualKeyId, string alertType) { - var cooldownKey = $"{AlertCooldownKeyPrefix}:{virtualKeyId}:{alertType}"; + var cooldownKey = RedisKeys.Spend.Cooldown(virtualKeyId.ToString(), alertType); return await _database.KeyExistsAsync(cooldownKey); } @@ -227,7 +222,7 @@ public async Task ResetBudgetAlertsAsync(int virtualKeyId) { try { - var pattern = $"{SentAlertsKeyPrefix}:{virtualKeyId}:*"; + var pattern = RedisKeys.Spend.SentAlertScanPattern(virtualKeyId.ToString()); var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints()[0]); var keys = server.Keys(pattern: pattern).ToArray(); @@ -245,14 +240,14 @@ public async Task ResetBudgetAlertsAsync(int virtualKeyId) public async Task RegisterInstanceAsync(string instanceId, object instanceData) { - var key = $"{InstancesSetKey}:{instanceId}"; + var key = RedisKeys.Spend.Instance(instanceId); await _database.HashSetAsync(key, "data", JsonSerializer.Serialize(instanceData)); await _database.KeyExpireAsync(key, TimeSpan.FromMinutes(2)); } public async Task UnregisterInstanceAsync(string instanceId) { - var key = $"{InstancesSetKey}:{instanceId}"; + var key = RedisKeys.Spend.Instance(instanceId); await _database.KeyDeleteAsync(key); } diff --git a/Services/ConduitLLM.Gateway/Services/SpendNotificationService.cs b/Services/ConduitLLM.Gateway/Services/SpendNotificationService.cs deleted file mode 100644 index d0375d4df..000000000 --- a/Services/ConduitLLM.Gateway/Services/SpendNotificationService.cs +++ /dev/null @@ -1,430 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.AspNetCore.SignalR; -using ConduitLLM.Configuration.DTOs.SignalR; -using ConduitLLM.Gateway.Hubs; -using ConduitLLM.Core.Interfaces; - -namespace ConduitLLM.Gateway.Services -{ - /// - /// Implementation of spend notification service. - /// - public class SpendNotificationService : ISpendNotificationService, IHostedService - { - private readonly IHubContext _hubContext; - private readonly IServiceScopeFactory _serviceScopeFactory; - private readonly ILogger _logger; - - // Track spending patterns per virtual key - private readonly ConcurrentDictionary _spendingPatterns = new(); - - // Track budget alert thresholds already sent to avoid spam - private readonly ConcurrentDictionary> _sentBudgetAlerts = new(); - - // Timer for periodic pattern analysis - private Timer? _patternAnalysisTimer; - private readonly TimeSpan _analysisInterval = TimeSpan.FromMinutes(5); - - public SpendNotificationService( - IHubContext hubContext, - IServiceScopeFactory serviceScopeFactory, - ILogger logger) - { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); - _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _patternAnalysisTimer = new Timer( - AnalyzeSpendingPatterns, - null, - _analysisInterval, - _analysisInterval); - - _logger.LogInformation("SpendNotificationService started"); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _patternAnalysisTimer?.Dispose(); - _logger.LogInformation("SpendNotificationService stopped"); - return Task.CompletedTask; - } - - public async Task NotifySpendUpdateAsync( - int virtualKeyId, - decimal amount, - decimal totalSpend, - decimal? budget, - string model, - string provider) - { - try - { - // Record the spend for pattern analysis - RecordSpend(virtualKeyId, amount); - - // Calculate budget percentage if budget is set - decimal? budgetPercentage = null; - if (budget.HasValue && budget.Value > 0) - { - budgetPercentage = (totalSpend / budget.Value) * 100; - - // Check budget thresholds and send alerts - await CheckBudgetThresholdsAsync(virtualKeyId, totalSpend, budget.Value, budgetPercentage.Value); - } - - var notification = new SpendUpdateNotification - { - NewSpend = amount, - TotalSpend = totalSpend, - Budget = budget, - BudgetPercentage = budgetPercentage, - Model = model, - Provider = provider, // Use provider name directly instead of ProviderType - Metadata = new RequestMetadata - { - RequestId = Guid.NewGuid().ToString(), - Endpoint = "/v1/chat/completions" // Should be passed in - } - }; - - // Get hub instance and send notification - using (var scope = _serviceScopeFactory.CreateScope()) - { - var hub = scope.ServiceProvider.GetService(); - if (hub != null) - { - await hub.SendSpendUpdate(virtualKeyId, notification); - } - else - { - // Fallback to hub context - var groupName = $"vkey-{virtualKeyId}"; - await _hubContext.Clients.Group(groupName).SendAsync("SpendUpdate", notification); - } - } - - // Check for unusual spending - await CheckUnusualSpendingAsync(virtualKeyId); - - _logger.LogInformation( - "Sent spend update for VirtualKey {VirtualKeyId}: ${Amount:F2} (Total: ${TotalSpend:F2})", - virtualKeyId, - amount, - totalSpend); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending spend update notification"); - // Don't throw - notifications should not break the main flow - } - } - - /// - /// Legacy method for backward compatibility - delegates to NotifySpendUpdateAsync - /// - public async Task NotifySpendUpdatedAsync(int virtualKeyId, decimal spendAmount, string model, string provider) - { - // For the legacy method, we don't have totalSpend or budget information - // So we'll call the new method with just the amount - await NotifySpendUpdateAsync(virtualKeyId, spendAmount, spendAmount, null, model, provider); - } - - public async Task SendSpendSummaryAsync(int virtualKeyId, SpendSummaryNotification summary) - { - try - { - var groupName = $"vkey-{virtualKeyId}"; - await _hubContext.Clients.Group(groupName).SendAsync("SpendSummary", summary); - - _logger.LogInformation( - "Sent {PeriodType} spend summary for VirtualKey {VirtualKeyId}: ${TotalSpend:F2}", - summary.PeriodType, - virtualKeyId, - summary.TotalSpend); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending spend summary notification"); - } - } - - public void RecordSpend(int virtualKeyId, decimal amount) - { - var pattern = _spendingPatterns.GetOrAdd(virtualKeyId, _ => new SpendingPattern()); - pattern.RecordSpend(amount); - } - - private async Task CheckBudgetThresholdsAsync(int virtualKeyId, decimal totalSpend, decimal budget, decimal percentageUsed) - { - try - { - // Get or create the set of sent alerts for this virtual key - var sentAlerts = _sentBudgetAlerts.GetOrAdd(virtualKeyId, _ => new HashSet()); - - // Define budget thresholds - var thresholds = new[] - { - (threshold: 50, severity: "info", message: "You have used 50% of your budget"), - (threshold: 75, severity: "warning", message: "You have used 75% of your budget"), - (threshold: 90, severity: "critical", message: "You have used 90% of your budget - approaching limit"), - (threshold: 100, severity: "critical", message: "Budget limit reached - further requests may be blocked") - }; - - foreach (var (threshold, severity, message) in thresholds) - { - if (percentageUsed >= threshold && !sentAlerts.Contains(threshold)) - { - // Send budget alert - var alertType = threshold switch - { - 50 => "budget_50_percent", - 75 => "budget_75_percent", - 90 => "budget_90_percent", - 100 => "budget_exceeded", - _ => "budget_threshold" - }; - - var recommendations = threshold switch - { - 50 => new List { "Monitor your usage patterns", "Consider optimizing model selection" }, - 75 => new List { "Review recent API usage", "Consider implementing caching", "Switch to more cost-effective models" }, - 90 => new List { "Urgent: Review and reduce API usage", "Implement rate limiting", "Consider increasing budget if needed" }, - 100 => new List { "API access may be restricted", "Increase budget immediately", "Review and optimize all API calls" }, - _ => new List() - }; - - var notification = new BudgetAlertNotification - { - AlertType = alertType, - Message = message, - CurrentSpend = totalSpend, - BudgetLimit = budget, - PercentageUsed = (double)percentageUsed, - Severity = severity, - Recommendations = recommendations - }; - - var groupName = $"vkey-{virtualKeyId}"; - await _hubContext.Clients.Group(groupName).SendAsync("BudgetAlert", notification); - - // Mark this threshold as sent - sentAlerts.Add(threshold); - - _logger.LogWarning( - "[SignalR:BudgetAlert] Sent notification - VirtualKey: {VirtualKeyId}, Threshold: {Threshold}%, CurrentSpend: ${CurrentSpend:F2}, Budget: ${Budget:F2}, AlertType: {AlertType}, Severity: {Severity}, Group: {GroupName}", - virtualKeyId, - threshold, - totalSpend, - budget, - alertType, - severity, - groupName); - } - } - - // Reset sent alerts if spending goes back down (e.g., new month) - if (percentageUsed < 50 && sentAlerts.Count() > 0) - { - sentAlerts.Clear(); - _logger.LogInformation("Budget alerts reset for VirtualKey {VirtualKeyId} as usage dropped below 50%", virtualKeyId); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking budget thresholds for VirtualKey {VirtualKeyId}", virtualKeyId); - } - } - - public async Task CheckUnusualSpendingAsync(int virtualKeyId) - { - try - { - if (!_spendingPatterns.TryGetValue(virtualKeyId, out var pattern)) - return; - - var analysis = pattern.AnalyzePattern(); - if (analysis.IsUnusual) - { - var notification = new UnusualSpendingNotification - { - ActivityType = analysis.PatternType, - Description = analysis.Description, - CurrentRate = analysis.CurrentRate, - NormalRate = analysis.NormalRate, - DeviationPercentage = (double)analysis.PercentageIncrease, - Recommendations = analysis.RecommendedActions - }; - - var groupName = $"vkey-{virtualKeyId}"; - await _hubContext.Clients.Group(groupName).SendAsync("UnusualSpendingDetected", notification); - - _logger.LogWarning( - "Unusual spending detected for VirtualKey {VirtualKeyId}: {PatternType} - {Description}", - virtualKeyId, - analysis.PatternType, - analysis.Description); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking unusual spending patterns"); - } - } - - private void AnalyzeSpendingPatterns(object? state) - { - // Fire and forget with proper error handling - _ = AnalyzeSpendingPatternsAsync(); - } - - private async Task AnalyzeSpendingPatternsAsync() - { - try - { - foreach (var kvp in _spendingPatterns) - { - await CheckUnusualSpendingAsync(kvp.Key); - } - - // Clean up old patterns (not accessed in 24 hours) - var cutoff = DateTime.UtcNow.AddHours(-24); - List keysToRemove = [ - .._spendingPatterns - .Where(kvp => kvp.Value.LastAccessed < cutoff) - .Select(kvp => kvp.Key) - ]; - - foreach (var key in keysToRemove) - { - _spendingPatterns.TryRemove(key, out _); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in pattern analysis timer"); - } - } - - /// - /// Tracks spending patterns for a virtual key. - /// - private class SpendingPattern - { - private readonly Queue _recentSpends = new(); - private readonly object _lock = new(); - - public DateTime LastAccessed { get; private set; } = DateTime.UtcNow; - - public void RecordSpend(decimal amount) - { - lock (_lock) - { - LastAccessed = DateTime.UtcNow; - _recentSpends.Enqueue(new SpendRecord { Amount = amount, Timestamp = DateTime.UtcNow }); - - // Keep only last hour of data - var cutoff = DateTime.UtcNow.AddHours(-1); - while (_recentSpends.Count() > 0 && _recentSpends.Peek().Timestamp < cutoff) - { - _recentSpends.Dequeue(); - } - } - } - - public PatternAnalysis AnalyzePattern() - { - lock (_lock) - { - if (_recentSpends.Count() < 5) // Need at least 5 records - { - return new PatternAnalysis { IsUnusual = false }; - } - - var now = DateTime.UtcNow; - List lastHour = [.._recentSpends.Where(s => s.Timestamp > now.AddHours(-1))]; - List previousHour = [.._recentSpends.Where(s => s.Timestamp <= now.AddHours(-1) && s.Timestamp > now.AddHours(-2))]; - - if (lastHour.Count() == 0 || previousHour.Count() == 0) - { - return new PatternAnalysis { IsUnusual = false }; - } - - var currentRate = lastHour.Sum(s => s.Amount); - var normalRate = previousHour.Sum(s => s.Amount); - - // Check for spike - if (normalRate > 0 && currentRate > normalRate * 3) - { - var percentageIncrease = ((currentRate - normalRate) / normalRate) * 100; - return new PatternAnalysis - { - IsUnusual = true, - PatternType = "spend_spike", - Description = $"Spending has increased by {percentageIncrease:F0}% in the last hour", - Severity = percentageIncrease > 500 ? "critical" : "warning", - CurrentRate = currentRate, - NormalRate = normalRate, - PercentageIncrease = percentageIncrease, - RecommendedActions = new List - { - "Review recent API usage", - "Check for runaway processes", - "Consider implementing rate limiting" - } - }; - } - - // Check for sustained high spending - var avgAmount = lastHour.Average(s => s.Amount); - if (avgAmount > 10 && lastHour.Count() > 20) // More than 20 requests in an hour with high avg cost - { - return new PatternAnalysis - { - IsUnusual = true, - PatternType = "sustained_high_spending", - Description = "Sustained high API usage detected", - Severity = "warning", - CurrentRate = currentRate, - NormalRate = normalRate, - PercentageIncrease = 0, - RecommendedActions = new List - { - "Review API usage patterns", - "Consider batch processing", - "Optimize model selection" - } - }; - } - - return new PatternAnalysis { IsUnusual = false }; - } - } - - private class SpendRecord - { - public decimal Amount { get; set; } - public DateTime Timestamp { get; set; } - } - } - - /// - /// Result of pattern analysis. - /// - private class PatternAnalysis - { - public bool IsUnusual { get; set; } - public string PatternType { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Severity { get; set; } = "info"; - public decimal CurrentRate { get; set; } - public decimal NormalRate { get; set; } - public decimal PercentageIncrease { get; set; } - public List RecommendedActions { get; set; } = new(); - } - } -} \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/SystemNotificationService.cs b/Services/ConduitLLM.Gateway/Services/SystemNotificationService.cs index 4c4a57c94..8dd96a679 100644 --- a/Services/ConduitLLM.Gateway/Services/SystemNotificationService.cs +++ b/Services/ConduitLLM.Gateway/Services/SystemNotificationService.cs @@ -1,124 +1,97 @@ using Microsoft.AspNetCore.SignalR; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Services; using ConduitLLM.Gateway.Hubs; namespace ConduitLLM.Gateway.Services { /// /// Implementation of ISystemNotificationService that uses SignalR hub context. + /// Inherits from SignalRNotificationServiceBase for common functionality. /// - public class SystemNotificationService : ISystemNotificationService + public class SystemNotificationService + : SignalRNotificationServiceBase, + ISystemNotificationService { - private readonly IHubContext _hubContext; - private readonly ILogger _logger; - /// /// Initializes a new instance of the class. /// - /// The SignalR hub context. - /// The logger instance. public SystemNotificationService( IHubContext hubContext, ILogger logger) + : base(hubContext, logger) { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task NotifyRateLimitWarning(int remaining, DateTime resetTime, string endpoint) { - try - { - await _hubContext.Clients.All.SendAsync("RateLimitWarning", remaining, resetTime, endpoint); - - _logger.LogInformation( - "Sent rate limit warning: {Remaining} requests remaining for {Endpoint}, resets at {ResetTime}", - remaining, - endpoint, - resetTime); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending rate limit warning"); - throw; - } + await ExecuteWithThrowAsync( + async () => await HubContext.Clients.All.SendAsync("RateLimitWarning", remaining, resetTime, endpoint), + nameof(NotifyRateLimitWarning), + "all clients"); + + Logger.LogInformation( + "Sent rate limit warning: {Remaining} requests remaining for {Endpoint}, resets at {ResetTime}", + remaining, + endpoint, + resetTime); } /// public async Task NotifySystemAnnouncement(string message, object priority) { - try - { - await _hubContext.Clients.All.SendAsync("SystemAnnouncement", message, priority.ToString()); - - _logger.LogInformation( - "Sent system announcement with {Priority} priority: {Message}", - priority, - message); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending system announcement"); - throw; - } + await ExecuteWithThrowAsync( + async () => await HubContext.Clients.All.SendAsync("SystemAnnouncement", message, priority.ToString()), + nameof(NotifySystemAnnouncement), + "all clients"); + + Logger.LogInformation( + "Sent system announcement with {Priority} priority: {Message}", + priority, + message); } /// public async Task NotifyServiceDegraded(string service, string reason) { - try - { - await _hubContext.Clients.All.SendAsync("ServiceDegraded", service, reason); - - _logger.LogWarning( - "Sent service degradation notification: {Service} is degraded - {Reason}", - service, - reason); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending service degradation notification"); - throw; - } + await ExecuteWithThrowAsync( + async () => await HubContext.Clients.All.SendAsync("ServiceDegraded", service, reason), + nameof(NotifyServiceDegraded), + "all clients"); + + Logger.LogWarning( + "Sent service degradation notification: {Service} is degraded - {Reason}", + service, + reason); } /// public async Task NotifyServiceRestored(string service) { - try - { - await _hubContext.Clients.All.SendAsync("ServiceRestored", service); - - _logger.LogInformation( - "Sent service restoration notification: {Service} has been restored", - service); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending service restoration notification"); - throw; - } + await ExecuteWithThrowAsync( + async () => await HubContext.Clients.All.SendAsync("ServiceRestored", service), + nameof(NotifyServiceRestored), + "all clients"); + + Logger.LogInformation( + "Sent service restoration notification: {Service} has been restored", + service); } /// - public async Task NotifyConfigurationChangedAsync(int virtualKeyId, string configurationType, System.Collections.Generic.List changedProperties) + public async Task NotifyConfigurationChangedAsync(int virtualKeyId, string configurationType, List changedProperties) { - try - { - await _hubContext.Clients.All.SendAsync("ConfigurationChanged", virtualKeyId, configurationType, changedProperties); - - _logger.LogInformation( - "Sent configuration change notification for VirtualKey {VirtualKeyId}: {ConfigurationType} - {Changes}", - virtualKeyId, - configurationType, - string.Join(", ", changedProperties)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending configuration change notification"); - throw; - } + await ExecuteWithThrowAsync( + async () => await HubContext.Clients.All.SendAsync("ConfigurationChanged", virtualKeyId, configurationType, changedProperties), + nameof(NotifyConfigurationChangedAsync), + "all clients"); + + Logger.LogInformation( + "Sent configuration change notification for VirtualKey {VirtualKeyId}: {ConfigurationType} - {Changes}", + virtualKeyId, + configurationType, + string.Join(", ", changedProperties)); } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Services/TaskProcessingMetricsService.cs b/Services/ConduitLLM.Gateway/Services/TaskProcessingMetricsService.cs index 6648281f7..739388232 100644 --- a/Services/ConduitLLM.Gateway/Services/TaskProcessingMetricsService.cs +++ b/Services/ConduitLLM.Gateway/Services/TaskProcessingMetricsService.cs @@ -188,7 +188,7 @@ private async Task CollectImageGenerationMetrics(IServiceScope scope) { State = g.Key.State, Count = g.Count(), - AvgDuration = g.Where(t => t.CompletedAt.HasValue).Count() > 0 + AvgDuration = g.Where(t => t.CompletedAt.HasValue).Any() ? g.Where(t => t.CompletedAt.HasValue) .Average(t => (double)((t.CompletedAt!.Value - t.CreatedAt).TotalSeconds)) : (double?)null @@ -234,7 +234,7 @@ private async Task CollectVideoGenerationMetrics(IServiceScope scope) { State = g.Key.State, Count = g.Count(), - AvgDuration = g.Where(t => t.CompletedAt.HasValue).Count() > 0 + AvgDuration = g.Where(t => t.CompletedAt.HasValue).Any() ? g.Where(t => t.CompletedAt.HasValue) .Average(t => (double)((t.CompletedAt!.Value - t.CreatedAt).TotalSeconds)) : (double?)null diff --git a/Services/ConduitLLM.Gateway/Services/ToolCostCalculationService.cs b/Services/ConduitLLM.Gateway/Services/ToolCostCalculationService.cs index ce6b01ee5..38b3e305a 100644 --- a/Services/ConduitLLM.Gateway/Services/ToolCostCalculationService.cs +++ b/Services/ConduitLLM.Gateway/Services/ToolCostCalculationService.cs @@ -1,10 +1,40 @@ using System.Text.Json; -using Microsoft.EntityFrameworkCore; using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Core.Interfaces; using ConduitLLM.Gateway.Middleware; +using Microsoft.EntityFrameworkCore; namespace ConduitLLM.Gateway.Services { + /// + /// Result of a tool cost calculation, including cost and diagnostic information. + /// + public class ToolCostResult + { + /// + /// Total calculated cost. -1 indicates a calculation failure. + /// + public decimal TotalCost { get; init; } + + /// + /// Tool names that were used but had no cost configuration. + /// Empty if all tools were configured. + /// + public List UnconfiguredToolNames { get; init; } = new(); + + /// + /// True if the cost calculation encountered an error. + /// + public bool Failed => TotalCost < 0; + + /// + /// True if some tools were used but had no cost configuration. + /// + public bool HasUnconfiguredTools => UnconfiguredToolNames.Count > 0; + } + /// /// Service for calculating costs of tool usage across different providers. /// @@ -12,11 +42,12 @@ public interface IToolCostCalculationService { /// /// Calculates the total cost for tool usage based on provider configuration. + /// Returns a result containing the cost and any unconfigured tool names. /// /// Tool usage data extracted from provider response /// The provider type to look up tool costs - /// Total cost for all tool usage - Task CalculateToolCostsAsync(ToolUsageData toolUsage, ProviderType providerType); + /// Tool cost result with cost and diagnostic info + Task CalculateToolCostsAsync(ToolUsageData toolUsage, ProviderType providerType); /// /// Serializes tool usage data to JSON for storage in BillingAuditEvent. @@ -28,46 +59,52 @@ public interface IToolCostCalculationService /// /// Implementation of tool cost calculation service. + /// Uses IProviderToolCache for high-performance lookups when available, + /// falls back to direct database queries otherwise. /// public class ToolCostCalculationService : IToolCostCalculationService { - private readonly ConduitDbContext _context; + private readonly IDbContextFactory _contextFactory; + private readonly IProviderToolCache? _cache; private readonly ILogger _logger; /// /// Initializes a new instance of the ToolCostCalculationService. /// - /// Database context for accessing tool configurations - /// Logger for error reporting - public ToolCostCalculationService(ConduitDbContext context, ILogger logger) + public ToolCostCalculationService( + IDbContextFactory contextFactory, + ILogger logger, + IProviderToolCache? cache = null) { - _context = context; + _contextFactory = contextFactory; _logger = logger; + _cache = cache; } /// - public async Task CalculateToolCostsAsync(ToolUsageData toolUsage, ProviderType providerType) + public async Task CalculateToolCostsAsync(ToolUsageData toolUsage, ProviderType providerType) { - if (toolUsage?.Tools == null || !toolUsage.Tools.Any()) - return 0; + if (toolUsage?.Tools == null || toolUsage.Tools.Count == 0) + return new ToolCostResult { TotalCost = 0 }; try { + // Batch-load all active tools for this provider (eliminates N+1) + var providerTools = await GetActiveToolsForProviderAsync(providerType); + var totalCost = 0m; + var unconfiguredTools = new List(); foreach (var toolUsageItem in toolUsage.Tools) { - var providerTool = await _context.ProviderTools - .FirstOrDefaultAsync(pt => - pt.Provider == providerType && - pt.ToolName == toolUsageItem.ToolName && - pt.IsActive); + var providerTool = providerTools + .Find(pt => pt.ToolName == toolUsageItem.ToolName); if (providerTool?.CostPerUnit.HasValue == true) { var usage = CalculateUsageAmount(toolUsageItem, providerTool.BillingUnit); var cost = providerTool.CostPerUnit.Value * usage; - + totalCost += cost; _logger.LogDebug("Tool cost calculated: {ToolName} = {Usage} {BillingUnit} ร— ${CostPerUnit} = ${Cost}", @@ -75,17 +112,23 @@ public async Task CalculateToolCostsAsync(ToolUsageData toolUsage, Prov } else { + unconfiguredTools.Add(toolUsageItem.ToolName); _logger.LogWarning("No cost configuration found for tool {ToolName} on provider {ProviderType}", toolUsageItem.ToolName, providerType); } } - return totalCost; + return new ToolCostResult + { + TotalCost = totalCost, + UnconfiguredToolNames = unconfiguredTools + }; } catch (Exception ex) { - _logger.LogError(ex, "Failed to calculate tool costs for provider {ProviderType}", providerType); - return 0; + _logger.LogError(ex, "Failed to calculate tool costs for provider {ProviderType}. " + + "Tool usage will be recorded but cost may be inaccurate.", providerType); + return new ToolCostResult { TotalCost = -1 }; } } @@ -98,7 +141,7 @@ public string SerializeToolUsage(ToolUsageData toolUsage) { return "{}"; } - + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, @@ -114,21 +157,75 @@ public string SerializeToolUsage(ToolUsageData toolUsage) } } + /// + /// Gets all active tools for a provider, using cache when available. + /// + private async Task> GetActiveToolsForProviderAsync(ProviderType providerType) + { + if (_cache != null) + { + return await _cache.GetActiveToolsForProviderAsync( + providerType, + LoadToolsFromDatabaseAsync); + } + + return await LoadToolsFromDatabaseAsync(providerType); + } + + /// + /// Loads active tools from the database for a given provider. + /// Used as the cache fallback function. + /// + private async Task> LoadToolsFromDatabaseAsync(ProviderType providerType) + { + await using var context = await _contextFactory.CreateDbContextAsync(); + return await context.ProviderTools + .Where(pt => pt.Provider == providerType && pt.IsActive) + .AsNoTracking() + .ToListAsync(); + } + /// /// Calculates the usage amount based on the tool's billing unit. + /// Supports DurationSeconds from provider responses with automatic unit conversion. /// - /// The tool usage item - /// The billing unit for this tool (requests, hours, etc.) - /// The usage amount for cost calculation private static decimal CalculateUsageAmount(ToolUsageItem toolUsageItem, string? billingUnit) { - return billingUnit?.ToLowerInvariant() switch + var unit = billingUnit?.ToLowerInvariant(); + return unit switch { - "hours" => toolUsageItem.Duration ?? toolUsageItem.Count, - "requests" => toolUsageItem.Count, - "minutes" => toolUsageItem.Duration ?? toolUsageItem.Count, - _ => toolUsageItem.Count // Default to count-based billing + ProviderToolBillingUnits.Hours => GetDurationInHours(toolUsageItem), + ProviderToolBillingUnits.Minutes => GetDurationInMinutes(toolUsageItem), + ProviderToolBillingUnits.Requests => toolUsageItem.Count, + ProviderToolBillingUnits.Searches => toolUsageItem.Count, + ProviderToolBillingUnits.Executions => toolUsageItem.Count, + ProviderToolBillingUnits.Characters => toolUsageItem.Count, + ProviderToolBillingUnits.Tokens => toolUsageItem.Count, + null or "" => toolUsageItem.Count, + _ => toolUsageItem.Count // Validated at save time, but defensive fallback }; } + + /// + /// Gets duration in hours, converting from DurationSeconds if available. + /// Falls back to Duration (already in hours), then to Count. + /// + private static decimal GetDurationInHours(ToolUsageItem item) + { + if (item.DurationSeconds.HasValue) + return item.DurationSeconds.Value / 3600m; + return item.Duration ?? item.Count; + } + + /// + /// Gets duration in minutes, converting from DurationSeconds if available. + /// Falls back to Duration (already in minutes), then to Count. + /// + private static decimal GetDurationInMinutes(ToolUsageItem item) + { + if (item.DurationSeconds.HasValue) + return item.DurationSeconds.Value / 60m; + return item.Duration ?? item.Count; + } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/UsageAnalyticsNotificationService.cs b/Services/ConduitLLM.Gateway/Services/UsageAnalyticsNotificationService.cs index 4da25c76b..f1c4f3dbe 100644 --- a/Services/ConduitLLM.Gateway/Services/UsageAnalyticsNotificationService.cs +++ b/Services/ConduitLLM.Gateway/Services/UsageAnalyticsNotificationService.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.SignalR; using ConduitLLM.Gateway.Hubs; using ConduitLLM.Configuration.DTOs.SignalR; +using ConduitLLM.Core.Services; namespace ConduitLLM.Gateway.Services { @@ -13,27 +14,27 @@ public interface IUsageAnalyticsNotificationService /// Sends usage metrics for a virtual key. /// Task SendUsageMetricsAsync(int virtualKeyId, UsageMetricsNotification metrics); - + /// /// Sends cost analytics for a virtual key. /// Task SendCostAnalyticsAsync(int virtualKeyId, CostAnalyticsNotification analytics); - + /// /// Sends performance metrics for a virtual key. /// Task SendPerformanceMetricsAsync(int virtualKeyId, PerformanceMetricsNotification metrics); - + /// /// Sends error analytics for a virtual key. /// Task SendErrorAnalyticsAsync(int virtualKeyId, ErrorAnalyticsNotification analytics); - + /// /// Sends global usage metrics to admin subscribers. /// Task SendGlobalUsageMetricsAsync(UsageMetricsNotification metrics); - + /// /// Sends global cost analytics to admin subscribers. /// @@ -42,174 +43,127 @@ public interface IUsageAnalyticsNotificationService /// /// Implementation of usage analytics notification service using SignalR. + /// Inherits from SignalRNotificationServiceBase for standardized error handling. /// - public class UsageAnalyticsNotificationService : IUsageAnalyticsNotificationService + public class UsageAnalyticsNotificationService + : SignalRNotificationServiceBase, + IUsageAnalyticsNotificationService { - private readonly IHubContext _hubContext; - private readonly ILogger _logger; - public UsageAnalyticsNotificationService( IHubContext hubContext, ILogger logger) + : base(hubContext, logger) { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task SendUsageMetricsAsync(int virtualKeyId, UsageMetricsNotification metrics) { - try + await SendToGroupAsync($"analytics-usage-{virtualKeyId}", "UsageMetrics", metrics); + + // If significant usage, also send to global analytics + if (metrics.RequestsPerMinute > 100 || metrics.TokensPerMinute > 10000) { - // Send to virtual key's usage analytics group - await _hubContext.Clients.Group($"analytics-usage-{virtualKeyId}").SendAsync("UsageMetrics", metrics); - - // If significant usage, also send to global analytics - if (metrics.RequestsPerMinute > 100 || metrics.TokensPerMinute > 10000) + await SendToGroupAsync("analytics-global-usage", "GlobalUsageMetrics", new { - await _hubContext.Clients.Group("analytics-global-usage").SendAsync("GlobalUsageMetrics", new - { - VirtualKeyId = virtualKeyId, - Metrics = metrics - }); - } - - _logger.LogDebug( - "Sent usage metrics for virtual key {VirtualKeyId}: {RequestsPerMinute} RPM, {TokensPerMinute} TPM", - virtualKeyId, - metrics.RequestsPerMinute, - metrics.TokensPerMinute); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send usage metrics for virtual key {VirtualKeyId}", virtualKeyId); + VirtualKeyId = virtualKeyId, + Metrics = metrics + }); } + + Logger.LogDebug( + "Sent usage metrics for virtual key {VirtualKeyId}: {RequestsPerMinute} RPM, {TokensPerMinute} TPM", + virtualKeyId, + metrics.RequestsPerMinute, + metrics.TokensPerMinute); } public async Task SendCostAnalyticsAsync(int virtualKeyId, CostAnalyticsNotification analytics) { - try + await SendToGroupAsync($"analytics-cost-{virtualKeyId}", "CostAnalytics", analytics); + + // If high cost rate, also send to global analytics + if (analytics.CostPerHour > 10.0m) { - // Send to virtual key's cost analytics group - await _hubContext.Clients.Group($"analytics-cost-{virtualKeyId}").SendAsync("CostAnalytics", analytics); - - // If high cost rate, also send to global analytics - if (analytics.CostPerHour > 10.0m) + await SendToGroupAsync("analytics-global-cost", "GlobalCostAnalytics", new { - await _hubContext.Clients.Group("analytics-global-cost").SendAsync("GlobalCostAnalytics", new - { - VirtualKeyId = virtualKeyId, - Analytics = analytics - }); - } - - _logger.LogInformation( - "Sent cost analytics for virtual key {VirtualKeyId}: ${TotalCost:F2} total, ${CostPerHour:F2}/hr", - virtualKeyId, - analytics.TotalCost, - analytics.CostPerHour); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send cost analytics for virtual key {VirtualKeyId}", virtualKeyId); + VirtualKeyId = virtualKeyId, + Analytics = analytics + }); } + + Logger.LogInformation( + "Sent cost analytics for virtual key {VirtualKeyId}: ${TotalCost:F2} total, ${CostPerHour:F2}/hr", + virtualKeyId, + analytics.TotalCost, + analytics.CostPerHour); } public async Task SendPerformanceMetricsAsync(int virtualKeyId, PerformanceMetricsNotification metrics) { - try + await SendToGroupAsync($"analytics-performance-{virtualKeyId}", "PerformanceMetrics", metrics); + + // If poor performance, also send to global analytics + if (metrics.AverageLatencyMs > 5000 || metrics.ErrorRate > 0.05) { - // Send to virtual key's performance analytics group - await _hubContext.Clients.Group($"analytics-performance-{virtualKeyId}").SendAsync("PerformanceMetrics", metrics); - - // If poor performance, also send to global analytics - if (metrics.AverageLatencyMs > 5000 || metrics.ErrorRate > 0.05) + await SendToGroupAsync("analytics-global-performance", "GlobalPerformanceMetrics", new { - await _hubContext.Clients.Group("analytics-global-performance").SendAsync("GlobalPerformanceMetrics", new - { - VirtualKeyId = virtualKeyId, - Metrics = metrics - }); - } - - _logger.LogDebug( - "Sent performance metrics for virtual key {VirtualKeyId}, model {Model}: {LatencyMs}ms avg latency", - virtualKeyId, - metrics.ModelName, - metrics.AverageLatencyMs); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send performance metrics for virtual key {VirtualKeyId}", virtualKeyId); + VirtualKeyId = virtualKeyId, + Metrics = metrics + }); } + + Logger.LogDebug( + "Sent performance metrics for virtual key {VirtualKeyId}, model {Model}: {LatencyMs}ms avg latency", + virtualKeyId, + metrics.ModelName, + metrics.AverageLatencyMs); } public async Task SendErrorAnalyticsAsync(int virtualKeyId, ErrorAnalyticsNotification analytics) { - try + await SendToGroupAsync($"analytics-errors-{virtualKeyId}", "ErrorAnalytics", analytics); + + // If high error rate, also send to global analytics + if (analytics.ErrorRate > 0.1 || analytics.TotalErrors > 100) { - // Send to virtual key's error analytics group - await _hubContext.Clients.Group($"analytics-errors-{virtualKeyId}").SendAsync("ErrorAnalytics", analytics); - - // If high error rate, also send to global analytics - if (analytics.ErrorRate > 0.1 || analytics.TotalErrors > 100) + await SendToGroupAsync("analytics-global-errors", "GlobalErrorAnalytics", new { - await _hubContext.Clients.Group("analytics-global-errors").SendAsync("GlobalErrorAnalytics", new - { - VirtualKeyId = virtualKeyId, - Analytics = analytics - }); - } - - _logger.LogWarning( - "Sent error analytics for virtual key {VirtualKeyId}: {ErrorCount} errors, {ErrorRate:P} error rate", - virtualKeyId, - analytics.TotalErrors, - analytics.ErrorRate); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send error analytics for virtual key {VirtualKeyId}", virtualKeyId); + VirtualKeyId = virtualKeyId, + Analytics = analytics + }); } + + Logger.LogWarning( + "Sent error analytics for virtual key {VirtualKeyId}: {ErrorCount} errors, {ErrorRate:P} error rate", + virtualKeyId, + analytics.TotalErrors, + analytics.ErrorRate); } public async Task SendGlobalUsageMetricsAsync(UsageMetricsNotification metrics) { - try - { - await _hubContext.Clients.Group("analytics-global-usage").SendAsync("GlobalUsageMetrics", new - { - Metrics = metrics - }); - - _logger.LogInformation( - "Sent global usage metrics: {RequestsPerMinute} RPM, {TokensPerMinute} TPM", - metrics.RequestsPerMinute, - metrics.TokensPerMinute); - } - catch (Exception ex) + await SendToGroupAsync("analytics-global-usage", "GlobalUsageMetrics", new { - _logger.LogError(ex, "Failed to send global usage metrics"); - } + Metrics = metrics + }); + + Logger.LogInformation( + "Sent global usage metrics: {RequestsPerMinute} RPM, {TokensPerMinute} TPM", + metrics.RequestsPerMinute, + metrics.TokensPerMinute); } public async Task SendGlobalCostAnalyticsAsync(CostAnalyticsNotification analytics) { - try + await SendToGroupAsync("analytics-global-cost", "GlobalCostAnalytics", new { - await _hubContext.Clients.Group("analytics-global-cost").SendAsync("GlobalCostAnalytics", new - { - Analytics = analytics - }); - - _logger.LogInformation( - "Sent global cost analytics: ${TotalCost:F2} total, ${CostPerHour:F2}/hr", - analytics.TotalCost, - analytics.CostPerHour); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send global cost analytics"); - } + Analytics = analytics + }); + + Logger.LogInformation( + "Sent global cost analytics: ${TotalCost:F2} total, ${CostPerHour:F2}/hr", + analytics.TotalCost, + analytics.CostPerHour); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/VideoGenerationNotificationService.cs b/Services/ConduitLLM.Gateway/Services/VideoGenerationNotificationService.cs index ab6103bdc..d6c825f84 100644 --- a/Services/ConduitLLM.Gateway/Services/VideoGenerationNotificationService.cs +++ b/Services/ConduitLLM.Gateway/Services/VideoGenerationNotificationService.cs @@ -1,136 +1,115 @@ using Microsoft.AspNetCore.SignalR; using ConduitLLM.Gateway.Hubs; using ConduitLLM.Core.Constants; - +using ConduitLLM.Core.Services; using ConduitLLM.Gateway.Interfaces; + namespace ConduitLLM.Gateway.Services { /// - /// Implementation of video generation notification service using SignalR + /// Implementation of video generation notification service using SignalR. + /// Inherits from SignalRNotificationServiceBase for common functionality. /// - public class VideoGenerationNotificationService : IVideoGenerationNotificationService + public class VideoGenerationNotificationService + : SignalRNotificationServiceBase, + IVideoGenerationNotificationService { - private readonly IHubContext _hubContext; - private readonly ILogger _logger; - public VideoGenerationNotificationService( IHubContext hubContext, ILogger logger) + : base(hubContext, logger) { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task NotifyVideoGenerationStartedAsync(string requestId, string provider, DateTime startedAt, int? estimatedSeconds) { - try - { - // Use taskId for consistency and send to specific group for security - var taskId = requestId; // requestId is actually taskId in the video generation flow - await _hubContext.Clients.Group(SignalRConstants.Groups.VideoTask(taskId)).SendAsync(SignalRConstants.ClientMethods.VideoGenerationStarted, new - { - taskId, // Changed from requestId to taskId for consistency - provider, - startedAt, - estimatedSeconds - }); - - _logger.LogDebug("Sent VideoGenerationStarted notification for task {TaskId}", taskId); - } - catch (Exception ex) + var groupName = SignalRConstants.Groups.VideoTask(requestId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.VideoGenerationStarted, new { - _logger.LogError(ex, "Failed to send VideoGenerationStarted notification for task {TaskId}", requestId); - } + taskId = requestId, + provider, + startedAt, + estimatedSeconds + }); + + Logger.LogDebug("Sent VideoGenerationStarted notification for task {TaskId}", requestId); } - public async Task NotifyVideoGenerationProgressAsync(string requestId, int progressPercentage, string status, string? message = null) + public async Task NotifyVideoGenerationProgressAsync(string requestId, int progressPercentage, string status, string? message = null, int? framesCompleted = null, int? totalFrames = null) { - try - { - // Use taskId for consistency and send to specific group for security - var taskId = requestId; // requestId is actually taskId in the video generation flow - await _hubContext.Clients.Group(SignalRConstants.Groups.VideoTask(taskId)).SendAsync(SignalRConstants.ClientMethods.VideoGenerationProgress, new - { - taskId, // Changed from requestId to taskId for consistency - progressPercentage, - status, - message, - timestamp = DateTime.UtcNow - }); - - _logger.LogDebug("Sent VideoGenerationProgress notification for task {TaskId}: {Progress}%", - taskId, progressPercentage); - } - catch (Exception ex) + var groupName = SignalRConstants.Groups.VideoTask(requestId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.VideoGenerationProgress, new { - _logger.LogError(ex, "Failed to send VideoGenerationProgress notification for task {TaskId}", requestId); - } + taskId = requestId, + progressPercentage, + status, + message, + framesCompleted, + totalFrames, + timestamp = DateTime.UtcNow + }); + + Logger.LogDebug("Sent VideoGenerationProgress notification for task {TaskId}: {Progress}%", + requestId, progressPercentage); } - public async Task NotifyVideoGenerationCompletedAsync(string requestId, string videoUrl, TimeSpan duration, decimal cost) + public async Task NotifyVideoGenerationCompletedAsync(string requestId, string videoUrl, TimeSpan duration, decimal cost, string? previewUrl = null, string? resolution = null, long? fileSize = null, string? provider = null, string? model = null, DateTime? completedAt = null, double? generationDurationSeconds = null) { - try - { - // Use taskId for consistency and send to specific group for security - var taskId = requestId; // requestId is actually taskId in the video generation flow - await _hubContext.Clients.Group(SignalRConstants.Groups.VideoTask(taskId)).SendAsync(SignalRConstants.ClientMethods.VideoGenerationCompleted, new - { - taskId, // Changed from requestId to taskId for consistency - videoUrl, - durationSeconds = duration.TotalSeconds, - cost, - completedAt = DateTime.UtcNow - }); - - _logger.LogDebug("Sent VideoGenerationCompleted notification for task {TaskId}", taskId); - } - catch (Exception ex) + var groupName = SignalRConstants.Groups.VideoTask(requestId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.VideoGenerationCompleted, new { - _logger.LogError(ex, "Failed to send VideoGenerationCompleted notification for task {TaskId}", requestId); - } + taskId = requestId, + status = "completed", + videoUrl, + previewUrl, + duration = duration.TotalSeconds, + resolution, + fileSize, + cost, + provider, + model, + completedAt = completedAt ?? DateTime.UtcNow, + generationDuration = generationDurationSeconds + }); + + Logger.LogDebug("Sent VideoGenerationCompleted notification for task {TaskId}", requestId); } - public async Task NotifyVideoGenerationFailedAsync(string requestId, string error, bool isRetryable) + public async Task NotifyVideoGenerationFailedAsync(string requestId, string error, bool isRetryable, string? errorCode = null, int? retryCount = null, int? maxRetries = null, DateTime? nextRetryAt = null, DateTime? failedAt = null) { - try - { - // Use taskId for consistency and send to specific group for security - var taskId = requestId; // requestId is actually taskId in the video generation flow - await _hubContext.Clients.Group(SignalRConstants.Groups.VideoTask(taskId)).SendAsync(SignalRConstants.ClientMethods.VideoGenerationFailed, new - { - taskId, // Changed from requestId to taskId for consistency - error, - isRetryable, - failedAt = DateTime.UtcNow - }); - - _logger.LogDebug("Sent VideoGenerationFailed notification for task {TaskId}", taskId); - } - catch (Exception ex) + var groupName = SignalRConstants.Groups.VideoTask(requestId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.VideoGenerationFailed, new { - _logger.LogError(ex, "Failed to send VideoGenerationFailed notification for task {TaskId}", requestId); - } + taskId = requestId, + status = "failed", + error, + errorCode, + isRetryable, + retryCount, + maxRetries, + nextRetryAt, + failedAt = failedAt ?? DateTime.UtcNow + }); + + Logger.LogDebug("Sent VideoGenerationFailed notification for task {TaskId}", requestId); } public async Task NotifyVideoGenerationCancelledAsync(string requestId, string? reason) { - try - { - // Use taskId for consistency and send to specific group for security - var taskId = requestId; // requestId is actually taskId in the video generation flow - await _hubContext.Clients.Group($"video-{taskId}").SendAsync("VideoGenerationCancelled", new - { - taskId, // Changed from requestId to taskId for consistency - reason, - cancelledAt = DateTime.UtcNow - }); - - _logger.LogDebug("Sent VideoGenerationCancelled notification for task {TaskId}", taskId); - } - catch (Exception ex) + var groupName = SignalRConstants.Groups.VideoTask(requestId); + + await SendToGroupAsync(groupName, SignalRConstants.ClientMethods.VideoGenerationCancelled, new { - _logger.LogError(ex, "Failed to send VideoGenerationCancelled notification for task {TaskId}", requestId); - } + taskId = requestId, + reason, + cancelledAt = DateTime.UtcNow + }); + + Logger.LogDebug("Sent VideoGenerationCancelled notification for task {TaskId}", requestId); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/VirtualKeyManagementNotificationService.cs b/Services/ConduitLLM.Gateway/Services/VirtualKeyManagementNotificationService.cs index ae293e495..7687fa1f8 100644 --- a/Services/ConduitLLM.Gateway/Services/VirtualKeyManagementNotificationService.cs +++ b/Services/ConduitLLM.Gateway/Services/VirtualKeyManagementNotificationService.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.SignalR; using ConduitLLM.Gateway.Hubs; using ConduitLLM.Configuration.DTOs.SignalR; +using ConduitLLM.Core.Services; namespace ConduitLLM.Gateway.Services { @@ -13,17 +14,17 @@ public interface IVirtualKeyManagementNotificationService /// Notifies about a virtual key creation. /// Task NotifyKeyCreatedAsync(VirtualKeyCreatedNotification notification); - + /// /// Notifies about a virtual key update. /// Task NotifyKeyUpdatedAsync(int virtualKeyId, VirtualKeyUpdatedNotification notification); - + /// /// Notifies about a virtual key deletion. /// Task NotifyKeyDeletedAsync(int virtualKeyId, VirtualKeyDeletedNotification notification); - + /// /// Notifies about a virtual key status change. /// @@ -32,112 +33,69 @@ public interface IVirtualKeyManagementNotificationService /// /// Implementation of virtual key management notification service using SignalR. + /// Inherits from SignalRNotificationServiceBase for standardized error handling. /// - public class VirtualKeyManagementNotificationService : IVirtualKeyManagementNotificationService + public class VirtualKeyManagementNotificationService + : SignalRNotificationServiceBase, + IVirtualKeyManagementNotificationService { - private readonly IHubContext _hubContext; - private readonly ILogger _logger; - public VirtualKeyManagementNotificationService( IHubContext hubContext, ILogger logger) + : base(hubContext, logger) { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task NotifyKeyCreatedAsync(VirtualKeyCreatedNotification notification) { - try - { - // Notify admin group about new key creation - await _hubContext.Clients.Group("admin").SendAsync("VirtualKeyCreated", notification); - - _logger.LogInformation( - "Sent VirtualKeyCreated notification for key {KeyName} (ID: {KeyId})", - notification.KeyName, - notification.KeyId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VirtualKeyCreated notification for key {KeyId}", notification.KeyId); - } + await SendToGroupAsync("admin", "VirtualKeyCreated", notification); + + Logger.LogInformation( + "Sent VirtualKeyCreated notification for key {KeyName} (ID: {KeyId})", + notification.KeyName, + notification.KeyId); } public async Task NotifyKeyUpdatedAsync(int virtualKeyId, VirtualKeyUpdatedNotification notification) { - try - { - // Notify the key's own group - await _hubContext.Clients.Group($"vkey-{virtualKeyId}").SendAsync("VirtualKeyUpdated", notification); - - // Notify management subscribers - await _hubContext.Clients.Group($"vkey-mgmt-{virtualKeyId}").SendAsync("VirtualKeyUpdated", notification); - - // Notify admin group - await _hubContext.Clients.Group("admin").SendAsync("VirtualKeyUpdated", notification); - - _logger.LogInformation( - "Sent VirtualKeyUpdated notification for key {KeyId}: {UpdatedProperties}", - virtualKeyId, - string.Join(", ", notification.UpdatedProperties)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VirtualKeyUpdated notification for key {KeyId}", virtualKeyId); - } + await SendToGroupAsync($"vkey-{virtualKeyId}", "VirtualKeyUpdated", notification); + await SendToGroupAsync($"vkey-mgmt-{virtualKeyId}", "VirtualKeyUpdated", notification); + await SendToGroupAsync("admin", "VirtualKeyUpdated", notification); + + Logger.LogInformation( + "Sent VirtualKeyUpdated notification for key {KeyId}: {UpdatedProperties}", + virtualKeyId, + string.Join(", ", notification.UpdatedProperties)); } public async Task NotifyKeyDeletedAsync(int virtualKeyId, VirtualKeyDeletedNotification notification) { - try - { - // Notify the key's own group - await _hubContext.Clients.Group($"vkey-{virtualKeyId}").SendAsync("VirtualKeyDeleted", notification); - - // Notify management subscribers - await _hubContext.Clients.Group($"vkey-mgmt-{virtualKeyId}").SendAsync("VirtualKeyDeleted", notification); - - // Notify admin group - await _hubContext.Clients.Group("admin").SendAsync("VirtualKeyDeleted", notification); - - _logger.LogInformation( - "Sent VirtualKeyDeleted notification for key {KeyName} (ID: {KeyId})", - notification.KeyName, - virtualKeyId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send VirtualKeyDeleted notification for key {KeyId}", virtualKeyId); - } + await SendToGroupAsync($"vkey-{virtualKeyId}", "VirtualKeyDeleted", notification); + await SendToGroupAsync($"vkey-mgmt-{virtualKeyId}", "VirtualKeyDeleted", notification); + await SendToGroupAsync("admin", "VirtualKeyDeleted", notification); + + Logger.LogInformation( + "Sent VirtualKeyDeleted notification for key {KeyName} (ID: {KeyId})", + notification.KeyName, + virtualKeyId); } public async Task NotifyKeyStatusChangedAsync(int virtualKeyId, VirtualKeyStatusChangedNotification notification) { - try - { - // Notify the key's own group - await _hubContext.Clients.Group($"vkey-{virtualKeyId}").SendAsync("VirtualKeyStatusChanged", notification); - - // Notify management subscribers - await _hubContext.Clients.Group($"vkey-mgmt-{virtualKeyId}").SendAsync("VirtualKeyStatusChanged", notification); - - // Notify admin group if it's a critical status change - if (notification.NewStatus == "disabled" || notification.NewStatus == "suspended") - { - await _hubContext.Clients.Group("admin").SendAsync("VirtualKeyStatusChanged", notification); - } - - _logger.LogInformation( - "Sent VirtualKeyStatusChanged notification for key {KeyId}: {PreviousStatus} -> {NewStatus}", - virtualKeyId, - notification.PreviousStatus, - notification.NewStatus); - } - catch (Exception ex) + await SendToGroupAsync($"vkey-{virtualKeyId}", "VirtualKeyStatusChanged", notification); + await SendToGroupAsync($"vkey-mgmt-{virtualKeyId}", "VirtualKeyStatusChanged", notification); + + // Notify admin group if it's a critical status change + if (notification.NewStatus == "disabled" || notification.NewStatus == "suspended") { - _logger.LogError(ex, "Failed to send VirtualKeyStatusChanged notification for key {KeyId}", virtualKeyId); + await SendToGroupAsync("admin", "VirtualKeyStatusChanged", notification); } + + Logger.LogInformation( + "Sent VirtualKeyStatusChanged notification for key {KeyId}: {PreviousStatus} -> {NewStatus}", + virtualKeyId, + notification.PreviousStatus, + notification.NewStatus); } } -} \ No newline at end of file +} diff --git a/Services/ConduitLLM.Gateway/Services/VirtualKeyRateLimitCache.cs b/Services/ConduitLLM.Gateway/Services/VirtualKeyRateLimitCache.cs index a65ecb9c7..70331c731 100644 --- a/Services/ConduitLLM.Gateway/Services/VirtualKeyRateLimitCache.cs +++ b/Services/ConduitLLM.Gateway/Services/VirtualKeyRateLimitCache.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; -using Microsoft.Extensions.Caching.Memory; +using ConduitLLM.Core.Constants; using ConduitLLM.Core.Interfaces; +using Microsoft.Extensions.Caching.Memory; using StackExchange.Redis; namespace ConduitLLM.Gateway.Services @@ -16,7 +17,6 @@ public class VirtualKeyRateLimitCache : IHostedService private readonly IConnectionMultiplexer? _redis; private Timer? _refreshTimer; - private const string REDIS_KEY_PREFIX = "rate:config:"; /// /// Represents rate limit configuration for a virtual key @@ -68,7 +68,7 @@ public VirtualKeyRateLimitCache( if (_redis != null && _redis.IsConnected) { var db = _redis.GetDatabase(); - var key = $"{REDIS_KEY_PREFIX}{virtualKeyHash}"; + var key = RedisKeys.RateLimit.Config(virtualKeyHash); var hashEntries = db.HashGetAll(key); if (hashEntries.Length > 0) @@ -125,7 +125,7 @@ public void UpdateRateLimits(string virtualKeyHash, int? rpm, int? rpd) if (_redis != null && _redis.IsConnected) { var db = _redis.GetDatabase(); - var key = $"{REDIS_KEY_PREFIX}{virtualKeyHash}"; + var key = RedisKeys.RateLimit.Config(virtualKeyHash); var transaction = db.CreateTransaction(); @@ -169,7 +169,7 @@ public void RemoveRateLimits(string virtualKeyHash) if (_redis != null && _redis.IsConnected) { var db = _redis.GetDatabase(); - var key = $"{REDIS_KEY_PREFIX}{virtualKeyHash}"; + var key = RedisKeys.RateLimit.Config(virtualKeyHash); // Fire and forget deletion _ = db.KeyDeleteAsync(key); diff --git a/Services/ConduitLLM.Gateway/Services/WebhookDeliveryNotificationService.cs b/Services/ConduitLLM.Gateway/Services/WebhookDeliveryNotificationService.cs index a9ee7236a..4866b73e2 100644 --- a/Services/ConduitLLM.Gateway/Services/WebhookDeliveryNotificationService.cs +++ b/Services/ConduitLLM.Gateway/Services/WebhookDeliveryNotificationService.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using Microsoft.AspNetCore.SignalR; using ConduitLLM.Configuration.DTOs.SignalR; +using ConduitLLM.Core.Extensions; using ConduitLLM.Gateway.Hubs; using ConduitLLM.Core.Services; @@ -318,21 +319,21 @@ public void RecordDeliveryAttempt(string webhookUrl) { // This is now handled by the metrics service when available // Keep as fallback for when Redis is not available - _logger.LogDebug("Recording delivery attempt for {WebhookUrl} (fallback mode)", webhookUrl); + _logger.LogDebug("Recording delivery attempt for {WebhookUrl} (fallback mode)", LoggingSanitizer.S(webhookUrl)); } public void RecordDeliverySuccess(string webhookUrl, long responseTimeMs) { // This is now handled by the metrics service when available // Keep as fallback for when Redis is not available - _logger.LogDebug("Recording delivery success for {WebhookUrl} (fallback mode)", webhookUrl); + _logger.LogDebug("Recording delivery success for {WebhookUrl} (fallback mode)", LoggingSanitizer.S(webhookUrl)); } public void RecordDeliveryFailure(string webhookUrl, bool isPermanent) { // This is now handled by the metrics service when available // Keep as fallback for when Redis is not available - _logger.LogDebug("Recording delivery failure for {WebhookUrl} (fallback mode)", webhookUrl); + _logger.LogDebug("Recording delivery failure for {WebhookUrl} (fallback mode)", LoggingSanitizer.S(webhookUrl)); } public async Task GetStatisticsAsync(string period = "last_hour") diff --git a/Services/ConduitLLM.Gateway/Startup.Production.cs b/Services/ConduitLLM.Gateway/Startup.Production.cs index 1558e2d98..0cfc76a1f 100644 --- a/Services/ConduitLLM.Gateway/Startup.Production.cs +++ b/Services/ConduitLLM.Gateway/Startup.Production.cs @@ -157,20 +157,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApp }).RequireAuthorization("AdminPolicy"); }); - // Log application startup - lifetime.ApplicationStarted.Register(() => - { - // Log.Information("Conduit Audio Service started successfully in {Environment} environment", - // env.EnvironmentName); - Console.WriteLine($"Conduit Audio Service started successfully in {env.EnvironmentName} environment"); - }); - - // Log application stopping - lifetime.ApplicationStopping.Register(() => - { - // Log.Information("Conduit Audio Service is shutting down"); - Console.WriteLine("Conduit Audio Service is shutting down"); - }); + // Application lifecycle logging handled by the framework } } } \ No newline at end of file diff --git a/Services/ConduitLLM.Gateway/Usage/UsageContext.cs b/Services/ConduitLLM.Gateway/Usage/UsageContext.cs new file mode 100644 index 000000000..271c2f7f4 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Usage/UsageContext.cs @@ -0,0 +1,59 @@ +namespace ConduitLLM.Gateway.UsageTracking; + +/// +/// Marker interface for typed usage-tracking context flowing from controllers to +/// UsageTrackingMiddleware. Replaces string-keyed HttpContext.Items +/// entries for request-shape data (model, size, quality, duration, etc.) with a +/// compile-checked carrier. Set via ; +/// read via . +/// +public interface IUsageContext +{ + /// Model alias as submitted by the caller (before provider resolution). + string Model { get; } +} + +/// +/// Image generation request context captured by ImagesController. +/// +public sealed class ImageUsageContext : IUsageContext +{ + public required string Model { get; init; } + public string? Quality { get; init; } + public string? Size { get; init; } + public int? N { get; init; } + public string? Style { get; init; } +} + +/// +/// Video generation request context captured by VideosController. +/// +public sealed class VideoUsageContext : IUsageContext +{ + public required string Model { get; init; } + public string? Size { get; init; } + public int? Duration { get; init; } + public int? Fps { get; init; } + public string? Style { get; init; } + public int? N { get; init; } + /// + /// Rules-based pricing parameters built from request fields plus ExtensionData + /// (e.g., resolution, duration, fps, with_audio, aspect_ratio). + /// + public Dictionary? PricingParameters { get; init; } +} + +public static class UsageContextExtensions +{ + private static readonly object Key = new(); + + public static void SetUsageContext(this HttpContext context, IUsageContext value) + { + context.Items[Key] = value; + } + + public static IUsageContext? GetUsageContext(this HttpContext context) + { + return context.Items.TryGetValue(Key, out var value) ? value as IUsageContext : null; + } +} diff --git a/Services/ConduitLLM.Gateway/Utilities/SignalRHubContextResolver.cs b/Services/ConduitLLM.Gateway/Utilities/SignalRHubContextResolver.cs new file mode 100644 index 000000000..54ac29ee9 --- /dev/null +++ b/Services/ConduitLLM.Gateway/Utilities/SignalRHubContextResolver.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.SignalR; + +namespace ConduitLLM.Gateway.Utilities; + +/// +/// Resolves SignalR hub contexts by hub name. Shared by SignalRMessageBatcher and +/// SignalRMessageQueueService to avoid duplicated hub resolution logic. +/// +internal static class SignalRHubContextResolver +{ + // Cache the resolved IHubContext<>-closed Type per hub name. Type.GetType + + // MakeGenericType is the expensive part and the result is process-wide invariant. + // A sentinel marker is used to memoize misses without re-running reflection. + private static readonly ConcurrentDictionary _contextTypeCache = new(); + + /// + /// Resolves an by hub class name from the Gateway assembly. + /// + public static IHubContext? Resolve(IServiceProvider serviceProvider, string hubName) + { + var contextType = _contextTypeCache.GetOrAdd(hubName, static name => + { + var hubType = Type.GetType($"ConduitLLM.Gateway.Hubs.{name}, ConduitLLM.Gateway"); + return hubType == null ? null : typeof(IHubContext<>).MakeGenericType(hubType); + }); + + return contextType == null ? null : serviceProvider.GetService(contextType) as IHubContext; + } +} diff --git a/Shared/ConduitLLM.Configuration/ConduitLLM.Configuration.csproj b/Shared/ConduitLLM.Configuration/ConduitLLM.Configuration.csproj index 10febb2d6..759cdcee0 100644 --- a/Shared/ConduitLLM.Configuration/ConduitLLM.Configuration.csproj +++ b/Shared/ConduitLLM.Configuration/ConduitLLM.Configuration.csproj @@ -4,26 +4,34 @@ net10.0 enable enable + + $(NoWarn);NU1510 - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + + + + + + - - + + - - + + + @@ -32,7 +40,7 @@ - + diff --git a/Shared/ConduitLLM.Configuration/Constants/CacheKeys.cs b/Shared/ConduitLLM.Configuration/Constants/CacheKeys.cs new file mode 100644 index 000000000..f98c04e17 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Constants/CacheKeys.cs @@ -0,0 +1,656 @@ +namespace ConduitLLM.Configuration.Constants; + +/// +/// Centralized cache key patterns for all services using Redis/distributed cache. +/// Use these constants to ensure consistent key naming and avoid collisions. +/// +/// +/// Key naming conventions: +/// - Use colons as separators (e.g., "vkey:hash:abc123") +/// - Keep prefixes short but descriptive +/// - Use lowercase for static parts +/// - Builder methods handle dynamic key construction +/// +public static class CacheKeys +{ + #region Virtual Key Cache + + /// + /// Cache keys for Virtual Key authentication and validation. + /// Used by RedisVirtualKeyCache for high-performance key lookups. + /// + public static class VirtualKey + { + /// Prefix for all virtual key cache entries + public const string Prefix = "vkey:"; + + /// Channel for single key invalidation notifications + public const string InvalidationChannel = "vkey_invalidated"; + + /// Channel for batch key invalidation notifications + public const string BatchInvalidationChannel = "vkey_batch_invalidated"; + + /// Builds a cache key for a virtual key by its hash + /// The hashed key value + /// Full cache key like "vkey:abc123" + public static string ByHash(string keyHash) => $"{Prefix}{keyHash}"; + } + + #endregion + + #region Model Cost Cache + + /// + /// Cache keys for Model Cost lookups and pattern matching. + /// Used by RedisModelCostCache for cost calculations. + /// + public static class ModelCost + { + /// Prefix for model cost entries by ID + public const string Prefix = "modelcost:"; + + /// Key for all model costs list cache + public const string All = "modelcost:all"; + + /// Prefix for model cost pattern lookups + public const string PatternPrefix = "modelcost:pattern:"; + + /// Prefix for provider-based model cost groupings (deprecated) + public const string ProviderPrefix = "modelcost:provider:"; + + /// Channel for cost invalidation notifications + public const string InvalidationChannel = "mcost_invalidated"; + + /// Channel for batch cost invalidation notifications + public const string BatchInvalidationChannel = "mcost_batch_invalidated"; + + /// Builds a cache key for a model cost by pattern + /// The model ID pattern (case-insensitive) + /// Full cache key like "modelcost:pattern:gpt-4" + public static string ByPattern(string modelIdPattern) => $"{PatternPrefix}{modelIdPattern.ToLowerInvariant()}"; + + /// Builds a cache key for a model cost by model ID + /// The model ID + /// Full cache key like "modelcost:pattern:gpt-4-turbo" + public static string ByModelId(string modelId) => ByPattern(modelId); + + /// Builds a cache key for a model cost by its database ID + /// The model cost ID + /// Full cache key like "modelcost:id:123" + public static string ById(int id) => $"modelcost:id:{id}"; + } + + #endregion + + #region Pricing Rules Cache + + /// + /// Cache keys for parsed pricing rules configurations. + /// Used by CachedPricingRulesService for deserialized PricingRulesConfig caching. + /// + public static class PricingRules + { + /// Prefix for pricing rules cache entries + public const string Prefix = "pricingrules:"; + + /// Builds a cache key for pricing rules by model cost ID + /// The model cost ID + /// Full cache key like "pricingrules:id:123" + public static string ById(int modelCostId) => $"pricingrules:id:{modelCostId}"; + } + + #endregion + + #region Global Setting Cache + + /// + /// Cache keys for Global Settings. + /// Used by RedisGlobalSettingCache for application configuration. + /// + public static class GlobalSetting + { + /// Prefix for all global setting cache entries + public const string Prefix = "globalsetting:"; + + /// Special key for authentication key caching with shorter TTL + public const string AuthKey = "globalsetting:authkey"; + + /// Builds a cache key for a global setting by key name + /// The setting key name + /// Full cache key like "globalsetting:maxrequests" + public static string ByKey(string settingKey) => $"{Prefix}{settingKey.ToLowerInvariant()}"; + } + + #endregion + + #region Provider Cache + + /// + /// Cache keys for Provider credentials and configuration. + /// Used by RedisProviderCache for provider lookups. + /// + public static class Provider + { + /// Prefix for provider cache entries by ID + public const string Prefix = "provider:"; + + /// Prefix for provider cache entries by name (deprecated - only for cleanup) + public const string NamePrefix = "provider:name:"; + + /// Builds a cache key for a provider by ID + /// The provider ID + /// Full cache key like "provider:123" + public static string ById(int providerId) => $"{Prefix}{providerId}"; + } + + #endregion + + #region IP Filter Cache + + /// + /// Cache keys for IP filtering rules. + /// Used by RedisIpFilterCache for security filtering. + /// + public static class IpFilter + { + /// Key for global IP filter rules + public const string GlobalFilters = "ipfilter:global"; + + /// Prefix for virtual key-specific IP filters + public const string VirtualKeyPrefix = "ipfilter:vkey:"; + + /// Prefix for IP check result caching + public const string CheckPrefix = "ipfilter:check:"; + + /// Builds a cache key for virtual key IP filters + /// The virtual key ID + /// Full cache key like "ipfilter:vkey:123" + public static string ByVirtualKey(int virtualKeyId) => $"{VirtualKeyPrefix}{virtualKeyId}"; + + /// Builds a cache key for IP check results + /// The IP address being checked + /// Optional virtual key ID, or null for global check + /// Full cache key like "ipfilter:check:192.168.1.1:123" or "ipfilter:check:192.168.1.1:global" + public static string CheckResult(string ipAddress, int? virtualKeyId) => + $"{CheckPrefix}{ipAddress}:{(virtualKeyId.HasValue ? virtualKeyId.Value.ToString() : "global")}"; + } + + #endregion + + #region Provider Tool Cache + + /// + /// Cache keys for Provider Tool cost lookups. + /// Used by RedisProviderToolCache for billing pipeline tool cost calculations. + /// + public static class ProviderTool + { + /// Prefix for provider tool cache entries by provider type + public const string Prefix = "providertool:"; + + /// Channel for tool invalidation notifications + public const string InvalidationChannel = "ptool_invalidated"; + + /// Builds a cache key for provider tools by provider type + /// The provider type enum value + /// Full cache key like "providertool:Groq" + public static string ByProvider(string providerType) => $"{Prefix}{providerType.ToLowerInvariant()}"; + } + + #endregion + + #region Ephemeral Key Cache + + /// + /// Cache keys for ephemeral (temporary) API keys. + /// Used by EphemeralKeyService and EphemeralMasterKeyService. + /// + public static class Ephemeral + { + /// Prefix for Gateway ephemeral keys + public const string Prefix = "ephemeral:"; + + /// Prefix for Admin master ephemeral keys + public const string MasterPrefix = "ephemeral:master:"; + + /// Token prefix for Gateway ephemeral keys (in the token itself) + public const string TokenPrefix = "ek_"; + + /// Token prefix for Admin master keys (in the token itself) + public const string MasterTokenPrefix = "emk_"; + + /// Builds a cache key for a Gateway ephemeral key + /// The ephemeral key token + /// Full cache key like "ephemeral:ek_abc123" + public static string ByToken(string token) => $"{Prefix}{token}"; + + /// Builds a cache key for an Admin master ephemeral key + /// The master key token + /// Full cache key like "ephemeral:master:emk_abc123" + public static string MasterByToken(string token) => $"{MasterPrefix}{token}"; + } + + #endregion + + #region Embedding Cache + + /// + /// Cache keys for embedding vector caching. + /// Used by RedisEmbeddingCache for cost optimization. + /// + public static class Embedding + { + /// Prefix for embedding cache entries + public const string Prefix = "emb:"; + + /// Key for embedding cache statistics + public const string StatsKey = "emb:stats"; + + /// Prefix for model-based embedding index + public const string IndexPrefix = "emb:idx:"; + + /// Builds a cache key for an embedding by its hash + /// The computed cache key hash + /// Full cache key like "emb:abc123def456" + public static string ByHash(string cacheKey) => $"{Prefix}{cacheKey}"; + + /// Builds an index key for model-based invalidation + /// The model name + /// Full index key like "emb:idx:text-embedding-ada-002" + public static string ModelIndex(string modelName) => $"{IndexPrefix}{modelName}"; + } + + #endregion + + #region Provider Error Cache + + /// + /// Cache keys for provider error tracking. + /// Used by RedisErrorStore for error monitoring and key disabling. + /// + public static class ProviderError + { + /// Key for recent errors feed (global) + public const string RecentFeed = "provider:errors:recent"; + + /// Builds a key for fatal error data by credential key ID + /// The provider key credential ID + /// Full key like "provider:errors:key:123:fatal" + public static string FatalByKey(int keyId) => $"provider:errors:key:{keyId}:fatal"; + + /// Builds a key for warning data by credential key ID + /// The provider key credential ID + /// Full key like "provider:errors:key:123:warnings" + public static string WarningsByKey(int keyId) => $"provider:errors:key:{keyId}:warnings"; + + /// Builds a key for provider-level error summary + /// The provider ID + /// Full key like "provider:errors:provider:456:summary" + public static string ProviderSummary(int providerId) => $"provider:errors:provider:{providerId}:summary"; + + /// Builds a key for provider-level disabled keys set + /// The provider ID + /// Full key like "provider:errors:provider:456:disabled_keys" + public static string DisabledKeysByProvider(int providerId) => $"provider:errors:provider:{providerId}:disabled_keys"; + } + + #endregion + + #region Model Mapping Cache + + /// + /// Cache keys for model-to-provider mapping lookups. + /// Used by CachedModelProviderMappingService and ModelMappingCacheInvalidationHandler. + /// + public static class ModelMapping + { + /// Prefix for model mapping cache entries + public const string Prefix = "model:mapping"; + + /// Key for all mappings list cache + public const string AllMappings = "model:mapping:all"; + + /// Builds a cache key for mapping by model alias + /// The model alias + /// Full cache key like "model:mapping:gpt-4" + public static string ByAlias(string modelAlias) => $"model:mapping:{modelAlias}"; + + /// Builds a cache key for mapping by ID + /// The mapping ID + /// Full cache key like "model:mapping:id:123" + public static string ById(int id) => $"model:mapping:id:{id}"; + } + + #endregion + + #region Media Progress Cache + + /// + /// Cache keys for media generation progress tracking. + /// Used by ImageGenerationProgressHandler and VideoGenerationProgressHandler. + /// + public static class MediaProgress + { + /// Prefix for image generation progress entries + public const string ImagePrefix = "image_generation_progress_"; + + /// Prefix for video generation progress entries + public const string VideoPrefix = "video_generation_progress_"; + + /// Builds a cache key for image generation progress + /// The generation task ID + /// Full cache key like "image_generation_progress_abc123" + public static string ImageProgress(string taskId) => $"{ImagePrefix}{taskId}"; + + /// Builds a cache key for video generation progress + /// The generation request ID + /// Full cache key like "video_generation_progress_abc123" + public static string VideoProgress(string requestId) => $"{VideoPrefix}{requestId}"; + } + + #endregion + + #region Statistics Cache + + /// + /// Cache keys for cache statistics tracking. + /// Used by various Redis cache implementations for metrics collection. + /// + public static class Stats + { + /// Service name for Virtual Key cache statistics + public const string VirtualKeyService = "vkey"; + + /// Service name for Model Cost cache statistics + public const string ModelCostService = "modelcost"; + + /// Service name for Global Setting cache statistics + public const string GlobalSettingService = "globalsetting"; + + /// Service name for Provider cache statistics + public const string ProviderService = "provider"; + + /// Service name for IP Filter cache statistics + public const string IpFilterService = "ipfilter"; + + /// Service name for Provider Tool cache statistics + public const string ProviderToolService = "providertool"; + + /// Builds a hits counter key for a service + /// The service name (use constants above) + /// Full key like "conduit:cache:modelcost:stats:hits" + public static string Hits(string service) => $"conduit:cache:{service}:stats:hits"; + + /// Builds a misses counter key for a service + /// The service name (use constants above) + /// Full key like "conduit:cache:modelcost:stats:misses" + public static string Misses(string service) => $"conduit:cache:{service}:stats:misses"; + + /// Builds an invalidations counter key for a service + /// The service name (use constants above) + /// Full key like "conduit:cache:modelcost:stats:invalidations" + public static string Invalidations(string service) => $"conduit:cache:{service}:stats:invalidations"; + + /// Builds a reset time key for a service + /// The service name (use constants above) + /// Full key like "conduit:cache:modelcost:stats:reset_time" + public static string ResetTime(string service) => $"conduit:cache:{service}:stats:reset_time"; + + /// Builds a pattern matches counter key (model cost specific) + /// Full key "conduit:cache:modelcost:stats:pattern_matches" + public static string PatternMatches() => "conduit:cache:modelcost:stats:pattern_matches"; + + /// Builds an auth hits counter key (global setting specific) + /// Full key "conduit:cache:globalsetting:stats:auth_hits" + public static string AuthHits() => "conduit:cache:globalsetting:stats:auth_hits"; + + /// Builds an auth misses counter key (global setting specific) + /// Full key "conduit:cache:globalsetting:stats:auth_misses" + public static string AuthMisses() => "conduit:cache:globalsetting:stats:auth_misses"; + + /// Builds an IP check counter key (IP filter specific) + /// Full key "conduit:cache:ipfilter:stats:ip_checks" + public static string IpChecks() => "conduit:cache:ipfilter:stats:ip_checks"; + + // Legacy pattern for VirtualKeyCache (uses shorter path without service name) + /// Legacy hits key for backward compatibility with VirtualKeyCache + public const string VirtualKeyHits = "conduit:cache:stats:hits"; + + /// Legacy misses key for backward compatibility with VirtualKeyCache + public const string VirtualKeyMisses = "conduit:cache:stats:misses"; + + /// Legacy invalidations key for backward compatibility with VirtualKeyCache + public const string VirtualKeyInvalidations = "conduit:cache:stats:invalidations"; + + /// Legacy reset time key for backward compatibility with VirtualKeyCache + public const string VirtualKeyResetTime = "conduit:cache:stats:reset_time"; + } + + #endregion + + #region Distributed Lock Keys + + /// + /// Cache keys for distributed lock operations (stampede prevention). + /// Used by IDistributedCachePopulator for concurrent cache population. + /// + public static class Locks + { + /// Prefix for all distributed lock keys + public const string Prefix = "populate:"; + + /// Builds a lock key for model cost pattern population + /// The model ID pattern + /// Full lock key like "populate:modelcost:pattern:gpt-4" + public static string ModelCostPattern(string pattern) => $"{Prefix}modelcost:pattern:{pattern.ToLowerInvariant()}"; + + /// Builds a lock key for model cost by model ID population + /// The model ID + /// Full lock key like "populate:modelcost:modelid:gpt-4-turbo" + public static string ModelCostModelId(string modelId) => $"{Prefix}modelcost:modelid:{modelId.ToLowerInvariant()}"; + + /// Builds a lock key for provider credential population + /// The provider ID + /// Full lock key like "populate:provider:123" + public static string Provider(int providerId) => $"{Prefix}provider:{providerId}"; + } + + #endregion + + #region Batch Idempotency Cache + + /// + /// Cache keys for batch operation idempotency tracking. + /// Used by BatchOperationIdempotencyService. + /// + public static class BatchIdempotency + { + /// Prefix for batch idempotency keys + public const string Prefix = "batch:idempotency:"; + + /// Builds a cache key for batch idempotency + /// The client-provided idempotency key + /// Full cache key like "batch:idempotency:abc123" + public static string ByKey(string idempotencyKey) => $"{Prefix}{idempotencyKey}"; + } + + #endregion + + #region Spend Notification Cache + + /// + /// Cache keys for spend notification and alerting. + /// Used by SpendDataRepository for budget tracking. + /// + public static class SpendNotification + { + /// Prefix for spending pattern data + public const string PatternsPrefix = "spend:patterns"; + + /// Prefix for sent alert tracking + public const string SentAlertsPrefix = "spend:alerts:sent"; + + /// Prefix for alert cooldown tracking + public const string CooldownPrefix = "spend:alerts:cooldown"; + + /// Key for spend history stream + public const string HistoryStream = "spend:history:stream"; + + /// Key for notification service instances set + public const string InstancesSet = "spend:notification:instances"; + + /// Builds a key for spending patterns by virtual key + /// The virtual key ID + /// Full key like "spend:patterns:123" + public static string PatternsByVirtualKey(int virtualKeyId) => $"{PatternsPrefix}:{virtualKeyId}"; + } + + #endregion + + #region Analytics Cache + + /// + /// Cache keys for analytics data (memory cache, not Redis). + /// Used by AnalyticsService for dashboard data. + /// + public static class Analytics + { + /// Prefix for analytics summary data + public const string SummaryPrefix = "analytics:summary:"; + + /// Key for models analytics cache + public const string Models = "analytics:models"; + + /// Prefix for cost trend data + public const string CostTrendPrefix = "analytics:cost:trend:"; + + /// Builds a cache key for analytics summary by date range + /// Start date + /// End date + /// Full cache key like "analytics:summary:20240101_20240131" + public static string Summary(DateTime startDate, DateTime endDate) => + $"{SummaryPrefix}{startDate:yyyyMMdd}_{endDate:yyyyMMdd}"; + + /// Builds a cache key for cost trend data + /// Start date + /// End date + /// Time granularity (hourly, daily, etc.) + /// Full cache key like "analytics:cost:trend:20240101_20240131_daily" + public static string CostTrend(DateTime startDate, DateTime endDate, string granularity) => + $"{CostTrendPrefix}{startDate:yyyyMMdd}_{endDate:yyyyMMdd}_{granularity}"; + } + + #endregion + + #region Performance Monitoring Cache + + /// + /// Cache keys for performance monitoring metrics. + /// Used by DistributedPerformanceMonitoringService. + /// + public static class Performance + { + /// Prefix for general performance metrics + public const string MetricsPrefix = "perf_metrics"; + + /// Prefix for endpoint-specific metrics + public const string EndpointMetricsPrefix = "endpoint_metrics"; + + /// Prefix for cache performance metrics + public const string CacheMetricsPrefix = "cache_metrics"; + + /// Prefix for connection pool metrics + public const string PoolMetricsPrefix = "pool_metrics"; + } + + #endregion + + #region Alert Management Cache + + /// + /// Cache keys for alert management. + /// Used by DistributedAlertManagementService. + /// + public static class AlertManagement + { + /// Prefix for alert history entries + public const string HistoryPrefix = "alert_history"; + + /// Prefix for alert locks (distributed locking) + public const string LockPrefix = "alert_lock"; + } + + #endregion + + #region SignalR Metrics Cache + + /// + /// Cache keys for SignalR connection metrics. + /// Used by DistributedSignalRMetricsService. + /// + public static class SignalRMetrics + { + /// Prefix for active connection tracking + public const string ConnectionsPrefix = "signalr_connections"; + + /// Prefix for virtual key connection mapping + public const string VirtualKeyConnectionsPrefix = "signalr_vk_connections"; + } + + #endregion + + #region Distributed Cache Statistics + + /// + /// Cache keys for distributed cache statistics collection. + /// Used by RedisCacheStatisticsCollector for cross-instance aggregation. + /// + public static class DistributedStats + { + /// Pattern for instance-specific stats hash: {region}:{instanceId} + public const string StatsHashPattern = "conduit:cache:stats:{0}:{1}"; + + /// Pattern for global stats hash: {region} + public const string GlobalStatsHashPattern = "conduit:cache:stats:{0}:global"; + + /// Pattern for response times: {region}:{operation}:{instanceId} + public const string ResponseTimesPattern = "conduit:cache:response:{0}:{1}:{2}"; + + /// Key for instance registry set + public const string InstanceSet = "conduit:cache:instances"; + + /// Pattern for instance heartbeat: {instanceId} + public const string HeartbeatPattern = "conduit:cache:heartbeat:{0}"; + + /// Pattern for alerts hash: {region} + public const string AlertsHashPattern = "conduit:cache:alerts:{0}"; + + /// Channel for stats update notifications + public const string UpdateChannel = "conduit:cache:stats:updates"; + + /// Channel for alert notifications + public const string AlertChannel = "conduit:cache:alerts"; + + /// Builds a stats hash key for an instance and region + public static string StatsHash(string region, string instanceId) => + string.Format(StatsHashPattern, region, instanceId); + + /// Builds a global stats hash key for a region + public static string GlobalStatsHash(string region) => + string.Format(GlobalStatsHashPattern, region); + + /// Builds a response times key + public static string ResponseTimes(string region, string operation, string instanceId) => + string.Format(ResponseTimesPattern, region, operation, instanceId); + + /// Builds a heartbeat key for an instance + public static string Heartbeat(string instanceId) => + string.Format(HeartbeatPattern, instanceId); + + /// Builds an alerts hash key for a region + public static string AlertsHash(string region) => + string.Format(AlertsHashPattern, region); + } + + #endregion +} diff --git a/Shared/ConduitLLM.Configuration/Constants/ProviderToolBillingUnits.cs b/Shared/ConduitLLM.Configuration/Constants/ProviderToolBillingUnits.cs new file mode 100644 index 000000000..57810d7f0 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Constants/ProviderToolBillingUnits.cs @@ -0,0 +1,35 @@ +namespace ConduitLLM.Configuration.Constants; + +/// +/// Valid billing units for provider tools. +/// Used for validation in the controller and cost calculation in the service. +/// +public static class ProviderToolBillingUnits +{ + public const string Requests = "requests"; + public const string Hours = "hours"; + public const string Minutes = "minutes"; + public const string Searches = "searches"; + public const string Executions = "executions"; + public const string Characters = "characters"; + public const string Tokens = "tokens"; + + /// + /// All valid billing units. + /// + public static readonly string[] All = + { + Requests, Hours, Minutes, Searches, Executions, Characters, Tokens + }; + + /// + /// Returns true if the given billing unit is valid. + /// + public static bool IsValid(string? billingUnit) + { + if (string.IsNullOrEmpty(billingUnit)) + return true; // null/empty defaults to count-based billing + + return Array.Exists(All, u => u.Equals(billingUnit, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Shared/ConduitLLM.Configuration/DTOs/Cache/CacheConfigurationDto.cs b/Shared/ConduitLLM.Configuration/DTOs/Cache/CacheConfigurationDto.cs deleted file mode 100644 index 915b38718..000000000 --- a/Shared/ConduitLLM.Configuration/DTOs/Cache/CacheConfigurationDto.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace ConduitLLM.Configuration.DTOs.Cache -{ - /// - /// Data transfer object for cache configuration response - /// - public class CacheConfigurationDto - { - /// - /// Response timestamp - /// - public DateTime Timestamp { get; set; } - - /// - /// List of cache policies - /// - public List CachePolicies { get; set; } = new(); - - /// - /// List of cache regions - /// - public List CacheRegions { get; set; } = new(); - - /// - /// Overall cache statistics - /// - public CacheStatisticsDto Statistics { get; set; } = new(); - - /// - /// Global cache configuration - /// - public CacheGlobalConfigDto Configuration { get; set; } = new(); - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/DTOs/LogRequestDto.cs b/Shared/ConduitLLM.Configuration/DTOs/LogRequestDto.cs index d10197c6e..cf1eeec32 100644 --- a/Shared/ConduitLLM.Configuration/DTOs/LogRequestDto.cs +++ b/Shared/ConduitLLM.Configuration/DTOs/LogRequestDto.cs @@ -45,6 +45,16 @@ public class LogRequestDto /// public int OutputTokens { get; set; } + /// + /// Number of input tokens read from cache. Null if caching was not used. + /// + public int? CachedInputTokens { get; set; } + + /// + /// Number of tokens written to cache. Null if caching was not used. + /// + public int? CachedWriteTokens { get; set; } + /// /// Cost of the request /// diff --git a/Shared/ConduitLLM.Configuration/DTOs/PromptCaching/PromptCachingDtos.cs b/Shared/ConduitLLM.Configuration/DTOs/PromptCaching/PromptCachingDtos.cs new file mode 100644 index 000000000..fe8ad2c77 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/DTOs/PromptCaching/PromptCachingDtos.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; + +namespace ConduitLLM.Configuration.DTOs.PromptCaching; + +/// +/// Response DTO for the current prompt caching configuration. +/// +public class PromptCachingConfigDto +{ + /// + /// Whether automatic cache_control injection is enabled. + /// + public bool AutoInjectEnabled { get; set; } + + /// + /// The injection points defining which messages get cache_control directives. + /// + public List InjectionPoints { get; set; } = new(); +} + +/// +/// Request DTO for updating the prompt caching configuration. +/// +public class UpdatePromptCachingConfigDto +{ + /// + /// Whether automatic cache_control injection is enabled. + /// + [Required] + public bool AutoInjectEnabled { get; set; } + + /// + /// The injection points defining which messages get cache_control directives. + /// Anthropic allows a maximum of 4 cache breakpoints per request. + /// + [Required] + [MaxLength(4, ErrorMessage = "Maximum 4 injection points (Anthropic limit)")] + public List InjectionPoints { get; set; } = new(); +} + +/// +/// DTO representing a single cache injection point target. +/// +public class CacheInjectionPointDto +{ + /// + /// Target by role: "system", "user", or "assistant". Null matches any role. + /// + [RegularExpression("^(system|user|assistant)$", ErrorMessage = "Role must be system, user, or assistant")] + public string? Role { get; set; } + + /// + /// Target by index: 0 = first matching, -1 = last matching, -2 = second-to-last. + /// Null means all messages matching the role filter. + /// + [Range(-100, 100)] + public int? Index { get; set; } +} diff --git a/Shared/ConduitLLM.Configuration/DTOs/RequestLogAggregations.cs b/Shared/ConduitLLM.Configuration/DTOs/RequestLogAggregations.cs new file mode 100644 index 000000000..cde9f5000 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/DTOs/RequestLogAggregations.cs @@ -0,0 +1,133 @@ +namespace ConduitLLM.Configuration.DTOs; + +/// +/// Aggregated cost data grouped by date, computed at the database level. +/// Used to replace in-memory GroupBy operations on full request log datasets. +/// +public class DateCostAggregation +{ + /// Date for this aggregation bucket + public DateTime Date { get; set; } + + /// Sum of all costs for this date + public decimal TotalCost { get; set; } + + /// Number of requests on this date + public int RequestCount { get; set; } +} + +/// +/// Aggregated request log data grouped by model, computed at the database level. +/// +public class ModelAggregation +{ + /// Model name + public string ModelName { get; set; } = string.Empty; + + /// Sum of all costs for this model + public decimal TotalCost { get; set; } + + /// Number of requests for this model + public int RequestCount { get; set; } + + /// Sum of input tokens + public long InputTokens { get; set; } + + /// Sum of output tokens + public long OutputTokens { get; set; } + + /// Sum of cached input tokens (read from cache) + public long CachedInputTokens { get; set; } + + /// Sum of cached write tokens + public long CachedWriteTokens { get; set; } +} + +/// +/// Aggregated request log data grouped by virtual key, computed at the database level. +/// +public class VirtualKeyAggregation +{ + /// Virtual key identifier + public int VirtualKeyId { get; set; } + + /// Sum of all costs for this key + public decimal TotalCost { get; set; } + + /// Number of requests for this key + public int RequestCount { get; set; } + + /// Most recent request timestamp + public DateTime LastUsed { get; set; } + + /// Number of distinct models used with this key + public int UniqueModels { get; set; } +} + +/// +/// Summary statistics for request logs within a date range, computed at the database level +/// as a single aggregate row (no grouping). +/// +public class RequestLogSummary +{ + /// Total number of requests + public int TotalRequests { get; set; } + + /// Sum of all costs + public decimal TotalCost { get; set; } + + /// Sum of input tokens + public long TotalInputTokens { get; set; } + + /// Sum of output tokens + public long TotalOutputTokens { get; set; } + + /// Sum of cached input tokens + public long TotalCachedInputTokens { get; set; } + + /// Sum of cached write tokens + public long TotalCachedWriteTokens { get; set; } + + /// Average response time in milliseconds + public double AverageResponseTimeMs { get; set; } + + /// Number of requests with status code in 200-299 range + public int SuccessCount { get; set; } + + /// Number of requests with status code >= 400 + public int ErrorCount { get; set; } +} + +/// +/// Aggregated daily statistics for request logs, computed at the database level. +/// Can be further aggregated to weekly/monthly in C# with minimal overhead. +/// +public class DailyStatisticsAggregation +{ + /// Date for these statistics + public DateTime Date { get; set; } + + /// Number of requests on this date + public int RequestCount { get; set; } + + /// Sum of all costs for this date + public decimal Cost { get; set; } + + /// Sum of input tokens for this date + public long InputTokens { get; set; } + + /// Sum of output tokens for this date + public long OutputTokens { get; set; } + + /// Sum of cached input tokens for this date + public long CachedInputTokens { get; set; } + + /// Sum of cached write tokens for this date + public long CachedWriteTokens { get; set; } + + /// Average response time in milliseconds for this date + public double AverageResponseTime { get; set; } + + /// Number of requests with status code >= 400 on this date + public int ErrorCount { get; set; } +} diff --git a/Shared/ConduitLLM.Configuration/DTOs/SignalR/BatchOperationNotifications.cs b/Shared/ConduitLLM.Configuration/DTOs/SignalR/BatchOperationNotifications.cs index db8e61d48..822b8331c 100644 --- a/Shared/ConduitLLM.Configuration/DTOs/SignalR/BatchOperationNotifications.cs +++ b/Shared/ConduitLLM.Configuration/DTOs/SignalR/BatchOperationNotifications.cs @@ -286,49 +286,4 @@ public class BatchOperationError public DateTime ErrorTime { get; set; } = DateTime.UtcNow; } - /// - /// Notification for batch operation item completion - /// - public class BatchOperationItemCompletedNotification - { - /// - /// Unique identifier for the batch operation - /// - public string OperationId { get; set; } = string.Empty; - - /// - /// Index of the completed item - /// - public int ItemIndex { get; set; } - - /// - /// Identifier for the completed item - /// - public string? ItemIdentifier { get; set; } - - /// - /// Whether the item was successful - /// - public bool Success { get; set; } - - /// - /// Error message if failed - /// - public string? Error { get; set; } - - /// - /// Processing duration for this item - /// - public TimeSpan Duration { get; set; } - - /// - /// Result data from processing - /// - public object? Result { get; set; } - - /// - /// Timestamp when the item completed - /// - public DateTime CompletedAt { get; set; } = DateTime.UtcNow; - } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/DTOs/VirtualKeyCountsDto.cs b/Shared/ConduitLLM.Configuration/DTOs/VirtualKeyCountsDto.cs new file mode 100644 index 000000000..37c12de2c --- /dev/null +++ b/Shared/ConduitLLM.Configuration/DTOs/VirtualKeyCountsDto.cs @@ -0,0 +1,27 @@ +namespace ConduitLLM.Configuration.DTOs; + +/// +/// Represents counts of virtual keys by status for dashboard and metrics purposes. +/// +public class VirtualKeyCountsDto +{ + /// + /// Number of active (enabled and non-expired) virtual keys. + /// + public int Active { get; set; } + + /// + /// Number of disabled virtual keys. + /// + public int Disabled { get; set; } + + /// + /// Number of expired virtual keys. + /// + public int Expired { get; set; } + + /// + /// Total number of virtual keys (Active + Disabled + Expired). + /// + public int Total => Active + Disabled + Expired; +} diff --git a/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContext.cs b/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContext.cs index c5a71025f..e1b763a95 100644 --- a/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContext.cs +++ b/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContext.cs @@ -148,16 +148,6 @@ public ConduitDbContext(DbContextOptions options) : base(optio /// public virtual DbSet BatchOperationHistory { get; set; } = null!; - /// - /// Database set for cache configurations - /// - public virtual DbSet CacheConfigurations { get; set; } = null!; - - /// - /// Database set for cache configuration audit logs - /// - public virtual DbSet CacheConfigurationAudits { get; set; } = null!; - // Function-related DbSets /// @@ -381,6 +371,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasKey(e => e.OperationId); + entity.Ignore(e => e.Id); entity.HasIndex(e => e.VirtualKeyId); entity.HasIndex(e => e.OperationType); entity.HasIndex(e => e.Status); @@ -394,40 +385,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); }); - // Configure CacheConfiguration entity - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - - // Apply filtered index only for non-test environments (PostgreSQL) - if (!IsTestEnvironment) - { - entity.HasIndex(e => e.Region).IsUnique().HasFilter("\"IsActive\" = true"); - } - else - { - // For SQLite in tests, use a regular unique index - entity.HasIndex(e => e.Region).IsUnique(); - } - - entity.HasIndex(e => new { e.Region, e.IsActive }); - entity.HasIndex(e => e.UpdatedAt); - entity.Property(e => e.Version).IsConcurrencyToken(); - - // Global query filter for active configurations (EF Core 10 named query filter) - entity.HasQueryFilter("Active", c => c.IsActive); - }); - - // Configure CacheConfigurationAudit entity - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.HasIndex(e => e.Region); - entity.HasIndex(e => e.ChangedAt); - entity.HasIndex(e => new { e.Region, e.ChangedAt }); - entity.HasIndex(e => e.ChangedBy); - }); - // Configure VirtualKeyGroupTransaction entity modelBuilder.Entity(entity => { diff --git a/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContextFactory.cs b/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContextFactory.cs index c95dc4d42..fb5d58c11 100644 --- a/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContextFactory.cs +++ b/Shared/ConduitLLM.Configuration/Data/ConfigurationDbContextFactory.cs @@ -24,8 +24,6 @@ public ConduitDbContext CreateDbContext(string[] args) "Example: postgresql://user:password@localhost:5432/conduitdb"); } - Console.WriteLine("Using database connection from environment: DATABASE_URL"); - // Parse the connection string if (connectionString.StartsWith("postgresql://") || connectionString.StartsWith("postgres://")) { diff --git a/Shared/ConduitLLM.Configuration/Entities/AsyncTask.cs b/Shared/ConduitLLM.Configuration/Entities/AsyncTask.cs index 246c765a0..63993947a 100644 --- a/Shared/ConduitLLM.Configuration/Entities/AsyncTask.cs +++ b/Shared/ConduitLLM.Configuration/Entities/AsyncTask.cs @@ -1,12 +1,14 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// /// Represents an asynchronous task with persistent storage. /// - public class AsyncTask + public class AsyncTask : IEntity, IAuditableEntity { /// /// Gets or sets the unique identifier for the task. diff --git a/Shared/ConduitLLM.Configuration/Entities/BatchOperationHistory.cs b/Shared/ConduitLLM.Configuration/Entities/BatchOperationHistory.cs index c7fe33f76..97611c215 100644 --- a/Shared/ConduitLLM.Configuration/Entities/BatchOperationHistory.cs +++ b/Shared/ConduitLLM.Configuration/Entities/BatchOperationHistory.cs @@ -1,13 +1,15 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// /// Entity for storing batch operation history /// [Table("BatchOperationHistory")] - public class BatchOperationHistory + public class BatchOperationHistory : IEntity { /// /// Unique identifier for the batch operation @@ -16,6 +18,17 @@ public class BatchOperationHistory [MaxLength(50)] public string OperationId { get; set; } = string.Empty; + /// + /// Implements by delegating to . + /// Not mapped to the database as OperationId is the actual column. + /// + [NotMapped] + public string Id + { + get => OperationId; + set => OperationId = value; + } + /// /// Type of batch operation (e.g., "spend_update", "virtual_key_update", "webhook_send") /// @@ -119,4 +132,4 @@ public class BatchOperationHistory /// public virtual VirtualKey VirtualKey { get; set; } = null!; } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Entities/BillingAuditEvent.cs b/Shared/ConduitLLM.Configuration/Entities/BillingAuditEvent.cs index d7b9fa0e1..be5d9fac3 100644 --- a/Shared/ConduitLLM.Configuration/Entities/BillingAuditEvent.cs +++ b/Shared/ConduitLLM.Configuration/Entities/BillingAuditEvent.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Functions.Interfaces; namespace ConduitLLM.Configuration.Entities { /// /// Represents an audit event for billing operations, tracking all billing decisions and failures /// - public class BillingAuditEvent + public class BillingAuditEvent : IAuditEvent { /// /// Unique identifier for the audit event diff --git a/Shared/ConduitLLM.Configuration/Entities/CacheConfiguration.cs b/Shared/ConduitLLM.Configuration/Entities/CacheConfiguration.cs deleted file mode 100644 index c3ea871ba..000000000 --- a/Shared/ConduitLLM.Configuration/Entities/CacheConfiguration.cs +++ /dev/null @@ -1,200 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace ConduitLLM.Configuration.Entities -{ - /// - /// Represents cache configuration settings for a specific region. - /// - public class CacheConfiguration - { - /// - /// Gets or sets the unique identifier. - /// - [Key] - public int Id { get; set; } - - /// - /// Gets or sets the cache region. - /// - [Required] - [MaxLength(50)] - public string Region { get; set; } = string.Empty; - - /// - /// Gets or sets whether caching is enabled for this region. - /// - public bool Enabled { get; set; } = true; - - /// - /// Gets or sets the default TTL in seconds. - /// - public int? DefaultTtlSeconds { get; set; } - - /// - /// Gets or sets the maximum TTL in seconds. - /// - public int? MaxTtlSeconds { get; set; } - - /// - /// Gets or sets the maximum number of entries. - /// - public long? MaxEntries { get; set; } - - /// - /// Gets or sets the maximum memory size in bytes. - /// - public long? MaxMemoryBytes { get; set; } - - /// - /// Gets or sets the eviction policy. - /// - [MaxLength(20)] - public string EvictionPolicy { get; set; } = "LRU"; - - /// - /// Gets or sets whether to use memory cache. - /// - public bool UseMemoryCache { get; set; } = true; - - /// - /// Gets or sets whether to use distributed cache. - /// - public bool UseDistributedCache { get; set; } = false; - - /// - /// Gets or sets whether compression is enabled. - /// - public bool EnableCompression { get; set; } = false; - - /// - /// Gets or sets the compression threshold in bytes. - /// - public long? CompressionThresholdBytes { get; set; } - - /// - /// Gets or sets the priority level (0-100). - /// - [Range(0, 100)] - public int Priority { get; set; } = 50; - - /// - /// Gets or sets whether detailed statistics are enabled. - /// - public bool EnableDetailedStats { get; set; } = true; - - /// - /// Gets or sets additional configuration as JSON. - /// - public string? ExtendedConfig { get; set; } - - /// - /// Gets or sets when this configuration was created. - /// - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets when this configuration was last updated. - /// - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets who created this configuration. - /// - [MaxLength(100)] - public string? CreatedBy { get; set; } - - /// - /// Gets or sets who last updated this configuration. - /// - [MaxLength(100)] - public string? UpdatedBy { get; set; } - - /// - /// Gets or sets the version number for optimistic concurrency. - /// - [Timestamp] - public byte[]? Version { get; set; } - - /// - /// Gets or sets whether this is the active configuration. - /// - public bool IsActive { get; set; } = true; - - /// - /// Gets or sets notes or description for this configuration. - /// - [MaxLength(500)] - public string? Notes { get; set; } - } - - /// - /// Represents an audit log entry for cache configuration changes. - /// - public class CacheConfigurationAudit - { - /// - /// Gets or sets the unique identifier. - /// - [Key] - public int Id { get; set; } - - /// - /// Gets or sets the cache region. - /// - [Required] - [MaxLength(50)] - public string Region { get; set; } = string.Empty; - - /// - /// Gets or sets the action performed. - /// - [Required] - [MaxLength(50)] - public string Action { get; set; } = string.Empty; - - /// - /// Gets or sets the old configuration as JSON. - /// - public string? OldConfigJson { get; set; } - - /// - /// Gets or sets the new configuration as JSON. - /// - public string? NewConfigJson { get; set; } - - /// - /// Gets or sets the reason for the change. - /// - [MaxLength(500)] - public string? Reason { get; set; } - - /// - /// Gets or sets who made the change. - /// - [Required] - [MaxLength(100)] - public string ChangedBy { get; set; } = string.Empty; - - /// - /// Gets or sets when the change was made. - /// - public DateTime ChangedAt { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets the source of the change (API, UI, System). - /// - [MaxLength(50)] - public string? ChangeSource { get; set; } - - /// - /// Gets or sets whether the change was successful. - /// - public bool Success { get; set; } = true; - - /// - /// Gets or sets any error message if the change failed. - /// - [MaxLength(1000)] - public string? ErrorMessage { get; set; } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Entities/GlobalSetting.cs b/Shared/ConduitLLM.Configuration/Entities/GlobalSetting.cs index 2511f1777..5b4e78912 100644 --- a/Shared/ConduitLLM.Configuration/Entities/GlobalSetting.cs +++ b/Shared/ConduitLLM.Configuration/Entities/GlobalSetting.cs @@ -1,11 +1,13 @@ using System.ComponentModel.DataAnnotations; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// /// Represents a global application setting /// - public class GlobalSetting + public class GlobalSetting : IEntity, IAuditableEntity { /// /// Unique identifier for the setting diff --git a/Shared/ConduitLLM.Configuration/Entities/Interfaces/IEntity.cs b/Shared/ConduitLLM.Configuration/Entities/Interfaces/IEntity.cs new file mode 100644 index 000000000..1164a992e --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Entities/Interfaces/IEntity.cs @@ -0,0 +1,47 @@ +using ConduitLLM.Functions.Entities.Interfaces; + +namespace ConduitLLM.Configuration.Entities.Interfaces; + +/// +/// Marker interface for configuration entities with a typed primary key. +/// Extends IIdentifiableEntity to share a common base with function entities, +/// enabling a single RepositoryBase for all entity types. +/// +/// The type of the primary key (e.g., int, long, Guid, string) +public interface IEntity : IIdentifiableEntity where TKey : IEquatable +{ +} + +/// +/// Marker interface for entities that track creation and update timestamps. +/// +public interface IAuditableEntity +{ + /// + /// Gets or sets the UTC timestamp when this entity was created. + /// + DateTime CreatedAt { get; set; } + + /// + /// Gets or sets the UTC timestamp when this entity was last updated. + /// + DateTime UpdatedAt { get; set; } +} + +/// +/// Marker interface for entities that support soft deletion. +/// Entities implementing this interface will not be permanently deleted, +/// but instead marked with IsDeleted = true and a DeletedAt timestamp. +/// +public interface ISoftDeletable +{ + /// + /// Gets or sets whether this entity has been soft deleted. + /// + bool IsDeleted { get; set; } + + /// + /// Gets or sets the UTC timestamp when this entity was soft deleted. + /// + DateTime? DeletedAt { get; set; } +} diff --git a/Shared/ConduitLLM.Configuration/Entities/IpFilterEntity.cs b/Shared/ConduitLLM.Configuration/Entities/IpFilterEntity.cs index cb4c9df94..d9f75752f 100644 --- a/Shared/ConduitLLM.Configuration/Entities/IpFilterEntity.cs +++ b/Shared/ConduitLLM.Configuration/Entities/IpFilterEntity.cs @@ -1,12 +1,14 @@ using System.ComponentModel.DataAnnotations; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities; /// /// Represents an IP address or subnet filter used for API access control. /// Supports both IPv4 and IPv6 addresses with CIDR notation. /// -public class IpFilterEntity +public class IpFilterEntity : IEntity, IAuditableEntity { /// /// Unique identifier for the IP filter diff --git a/Shared/ConduitLLM.Configuration/Entities/MediaRecord.cs b/Shared/ConduitLLM.Configuration/Entities/MediaRecord.cs index 8ec876dc2..60da7fa2e 100644 --- a/Shared/ConduitLLM.Configuration/Entities/MediaRecord.cs +++ b/Shared/ConduitLLM.Configuration/Entities/MediaRecord.cs @@ -1,13 +1,15 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// /// Represents a media file (image or video) generated through Conduit. /// [Table("MediaRecords")] - public class MediaRecord + public class MediaRecord : IEntity { /// /// Gets or sets the unique identifier for the media record. diff --git a/Shared/ConduitLLM.Configuration/Entities/Model.cs b/Shared/ConduitLLM.Configuration/Entities/Model.cs index bfa1df74f..cba18a6da 100644 --- a/Shared/ConduitLLM.Configuration/Entities/Model.cs +++ b/Shared/ConduitLLM.Configuration/Entities/Model.cs @@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// @@ -9,7 +11,7 @@ namespace ConduitLLM.Configuration.Entities /// This is a convenient way to associate costs, capabilities, and configurations with a specific model. /// We are assuming that the cost is primarily determined by the model variant and its associated provider. /// - public class Model + public class Model : IEntity, IAuditableEntity { [Key] public int Id { get; set; } diff --git a/Shared/ConduitLLM.Configuration/Entities/ModelAuthor.cs b/Shared/ConduitLLM.Configuration/Entities/ModelAuthor.cs index e54fa2d10..8f2e34430 100644 --- a/Shared/ConduitLLM.Configuration/Entities/ModelAuthor.cs +++ b/Shared/ConduitLLM.Configuration/Entities/ModelAuthor.cs @@ -1,9 +1,11 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { - public class ModelAuthor + public class ModelAuthor : IEntity { [Key] public int Id { get; set; } diff --git a/Shared/ConduitLLM.Configuration/Entities/ModelCost.cs b/Shared/ConduitLLM.Configuration/Entities/ModelCost.cs index f49ae7247..28430ab53 100644 --- a/Shared/ConduitLLM.Configuration/Entities/ModelCost.cs +++ b/Shared/ConduitLLM.Configuration/Entities/ModelCost.cs @@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities; /// @@ -14,7 +16,7 @@ namespace ConduitLLM.Configuration.Entities; /// The pricing information is used to calculate costs for each request processed through the system, /// enabling detailed cost reporting and budget management. /// -public class ModelCost +public class ModelCost : IEntity, IAuditableEntity { /// /// Gets or sets the unique identifier for the model cost entry. diff --git a/Shared/ConduitLLM.Configuration/Entities/ModelProviderMapping.cs b/Shared/ConduitLLM.Configuration/Entities/ModelProviderMapping.cs index ef242ac5f..70def04b7 100644 --- a/Shared/ConduitLLM.Configuration/Entities/ModelProviderMapping.cs +++ b/Shared/ConduitLLM.Configuration/Entities/ModelProviderMapping.cs @@ -2,14 +2,16 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// - /// Maps a generic model alias (e.g., "gpt-4-turbo") to a specific provider's model name - /// and associates it with provider credentials. This entity enables routing requests to + /// Maps a generic model alias (e.g., "gpt-4-turbo") to a specific provider's model name + /// and associates it with provider credentials. This entity enables routing requests to /// specific provider models regardless of the model name used in the request. /// - public class ModelProviderMapping + public class ModelProviderMapping : IEntity, IAuditableEntity { /// /// Unique identifier for the model-provider mapping. diff --git a/Shared/ConduitLLM.Configuration/Entities/ModelSeries.cs b/Shared/ConduitLLM.Configuration/Entities/ModelSeries.cs index b77ca0dc6..d8aab79b2 100644 --- a/Shared/ConduitLLM.Configuration/Entities/ModelSeries.cs +++ b/Shared/ConduitLLM.Configuration/Entities/ModelSeries.cs @@ -2,9 +2,11 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { - public class ModelSeries + public class ModelSeries : IEntity { [Key] public int Id { get; set; } diff --git a/Shared/ConduitLLM.Configuration/Entities/Notification.cs b/Shared/ConduitLLM.Configuration/Entities/Notification.cs index 26f301756..b180da2ca 100644 --- a/Shared/ConduitLLM.Configuration/Entities/Notification.cs +++ b/Shared/ConduitLLM.Configuration/Entities/Notification.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// @@ -48,7 +50,7 @@ public enum NotificationSeverity /// /// Represents a notification related to virtual keys /// - public class Notification + public class Notification : IEntity { /// /// Unique identifier for the notification diff --git a/Shared/ConduitLLM.Configuration/Entities/PricingAuditEvent.cs b/Shared/ConduitLLM.Configuration/Entities/PricingAuditEvent.cs index c0a7cb66f..e394b0355 100644 --- a/Shared/ConduitLLM.Configuration/Entities/PricingAuditEvent.cs +++ b/Shared/ConduitLLM.Configuration/Entities/PricingAuditEvent.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Functions.Interfaces; namespace ConduitLLM.Configuration.Entities; @@ -7,7 +8,7 @@ namespace ConduitLLM.Configuration.Entities; /// Represents an audit event for rules-based pricing evaluations. /// Tracks pricing decisions for billing disputes and analytics. /// -public class PricingAuditEvent +public class PricingAuditEvent : IAuditEvent { /// /// Unique identifier for the audit event. diff --git a/Shared/ConduitLLM.Configuration/Entities/Provider.cs b/Shared/ConduitLLM.Configuration/Entities/Provider.cs index c9be53807..334c0d4f8 100644 --- a/Shared/ConduitLLM.Configuration/Entities/Provider.cs +++ b/Shared/ConduitLLM.Configuration/Entities/Provider.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// @@ -8,7 +10,7 @@ namespace ConduitLLM.Configuration.Entities /// This is the main entity for managing provider configurations and serves as the parent /// for multiple API keys through the ProviderKeyCredentials collection. /// - public class Provider + public class Provider : IEntity, IAuditableEntity { /// /// Gets or sets the unique identifier for this provider. diff --git a/Shared/ConduitLLM.Configuration/Entities/ProviderKeyCredential.cs b/Shared/ConduitLLM.Configuration/Entities/ProviderKeyCredential.cs index 2f122c135..ed3c0f34b 100644 --- a/Shared/ConduitLLM.Configuration/Entities/ProviderKeyCredential.cs +++ b/Shared/ConduitLLM.Configuration/Entities/ProviderKeyCredential.cs @@ -1,6 +1,9 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; +using ConduitLLM.Functions.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// @@ -8,7 +11,7 @@ namespace ConduitLLM.Configuration.Entities /// Multiple key credentials can be associated with a single provider for load balancing, /// failover, and account-based organization. /// - public class ProviderKeyCredential + public class ProviderKeyCredential : IEntity, IAuditableEntity, ICredentialEntity { /// /// Gets or sets the unique identifier for this provider key credential. diff --git a/Shared/ConduitLLM.Configuration/Entities/RequestLog.cs b/Shared/ConduitLLM.Configuration/Entities/RequestLog.cs index 71419de63..233cbdeda 100644 --- a/Shared/ConduitLLM.Configuration/Entities/RequestLog.cs +++ b/Shared/ConduitLLM.Configuration/Entities/RequestLog.cs @@ -1,12 +1,15 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; +using ConduitLLM.Functions.Interfaces; + namespace ConduitLLM.Configuration.Entities; /// /// Represents a log of API requests made using a virtual key /// -public class RequestLog +public class RequestLog : IEntity, IAuditEvent { /// /// Unique identifier for the request log @@ -62,6 +65,16 @@ public class RequestLog /// public int OutputTokens { get; set; } + /// + /// Number of input tokens read from cache. Null if caching was not used. + /// + public int? CachedInputTokens { get; set; } + + /// + /// Number of tokens written to cache. Null if caching was not used. + /// + public int? CachedWriteTokens { get; set; } + /// /// Cost of the request /// diff --git a/Shared/ConduitLLM.Configuration/Entities/VirtualKey.cs b/Shared/ConduitLLM.Configuration/Entities/VirtualKey.cs index fc5381579..1c60e4e9c 100644 --- a/Shared/ConduitLLM.Configuration/Entities/VirtualKey.cs +++ b/Shared/ConduitLLM.Configuration/Entities/VirtualKey.cs @@ -1,11 +1,13 @@ using System.ComponentModel.DataAnnotations; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities; /// /// Represents a virtual API key for accessing LLM services /// -public partial class VirtualKey +public partial class VirtualKey : IEntity, IAuditableEntity { /// /// Unique identifier for the virtual key diff --git a/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroup.cs b/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroup.cs index 04da88060..48377c2b1 100644 --- a/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroup.cs +++ b/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroup.cs @@ -1,12 +1,14 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities; /// /// Represents a group of virtual keys that share a common balance /// -public class VirtualKeyGroup +public class VirtualKeyGroup : IEntity, IAuditableEntity { /// /// Unique identifier for the virtual key group diff --git a/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroupTransaction.cs b/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroupTransaction.cs index 60dfa053e..f8ef4601e 100644 --- a/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroupTransaction.cs +++ b/Shared/ConduitLLM.Configuration/Entities/VirtualKeyGroupTransaction.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; using ConduitLLM.Configuration.Enums; namespace ConduitLLM.Configuration.Entities @@ -8,7 +9,7 @@ namespace ConduitLLM.Configuration.Entities /// /// Represents a transaction that modifies a virtual key group's balance /// - public class VirtualKeyGroupTransaction + public class VirtualKeyGroupTransaction : IEntity, ISoftDeletable { /// /// Primary key diff --git a/Shared/ConduitLLM.Configuration/Entities/VirtualKeySpendHistory.cs b/Shared/ConduitLLM.Configuration/Entities/VirtualKeySpendHistory.cs index 6bf2dcb5e..7787f803e 100644 --- a/Shared/ConduitLLM.Configuration/Entities/VirtualKeySpendHistory.cs +++ b/Shared/ConduitLLM.Configuration/Entities/VirtualKeySpendHistory.cs @@ -1,12 +1,14 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Configuration.Entities.Interfaces; + namespace ConduitLLM.Configuration.Entities { /// /// Represents the spending history for a virtual key /// - public class VirtualKeySpendHistory + public class VirtualKeySpendHistory : IEntity { /// /// Unique identifier for the spend history record diff --git a/Shared/ConduitLLM.Configuration/Enums/ProviderType.cs b/Shared/ConduitLLM.Configuration/Enums/ProviderType.cs index 2c6caf2da..1ff2e0403 100644 --- a/Shared/ConduitLLM.Configuration/Enums/ProviderType.cs +++ b/Shared/ConduitLLM.Configuration/Enums/ProviderType.cs @@ -61,6 +61,16 @@ public enum ProviderType /// /// DeepInfra (OpenAI-compatible LLM inference platform) /// - DeepInfra = 11 + DeepInfra = 11, + + /// + /// Cloudflare Workers AI (serverless AI inference on Cloudflare's global network) + /// + Cloudflare = 12, + + /// + /// OpenRouter (multi-provider routing via OpenAI-compatible API) + /// + OpenRouter = 13 } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Events/CacheConfigurationChangedEvent.cs b/Shared/ConduitLLM.Configuration/Events/CacheConfigurationChangedEvent.cs deleted file mode 100644 index 4f813f7cb..000000000 --- a/Shared/ConduitLLM.Configuration/Events/CacheConfigurationChangedEvent.cs +++ /dev/null @@ -1,72 +0,0 @@ -using ConduitLLM.Configuration.Models; - -namespace ConduitLLM.Configuration.Events -{ - /// - /// Event raised when cache configuration is changed. - /// - public class CacheConfigurationChangedEvent - { - /// - /// Gets or sets the cache region that was changed. - /// - public string Region { get; set; } = string.Empty; - - /// - /// Gets or sets the action performed (Created, Updated, Deleted). - /// - public string Action { get; set; } = string.Empty; - - /// - /// Gets or sets the old configuration. - /// - public CacheRegionConfig? OldConfig { get; set; } - - /// - /// Gets or sets the new configuration. - /// - public CacheRegionConfig? NewConfig { get; set; } - - /// - /// Gets or sets who made the change. - /// - public string ChangedBy { get; set; } = string.Empty; - - /// - /// Gets or sets when the change occurred. - /// - public DateTime ChangedAt { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets the reason for the change. - /// - public string? Reason { get; set; } - - /// - /// Gets or sets whether the change should be applied immediately. - /// - public bool ApplyImmediately { get; set; } = true; - - /// - /// Gets or sets the rollout percentage (0-100) for gradual rollout. - /// - public int RolloutPercentage { get; set; } = 100; - - /// - /// Gets or sets whether this is a rollback operation. - /// - public bool IsRollback { get; set; } - - /// - /// Gets or sets the source system of the change. - /// - public string? ChangeSource { get; set; } - } - - /// - /// Event consumer interface for cache configuration changes. - /// - public interface ICacheConfigurationChangedConsumer : MassTransit.IConsumer - { - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Exceptions/UnboundedQueryException.cs b/Shared/ConduitLLM.Configuration/Exceptions/UnboundedQueryException.cs new file mode 100644 index 000000000..942bf992b --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Exceptions/UnboundedQueryException.cs @@ -0,0 +1,48 @@ +using System; + +namespace ConduitLLM.Configuration.Exceptions +{ + /// + /// Exception thrown when an unbounded query is attempted on a high-risk table. + /// This prevents accidental full table scans on tables that could contain millions of records. + /// + public class UnboundedQueryException : InvalidOperationException + { + /// + /// Gets the entity type that was queried. + /// + public string EntityType { get; } + + /// + /// Gets the method name that was called. + /// + public string MethodName { get; } + + /// + /// Initializes a new instance of the UnboundedQueryException class. + /// + /// The entity type being queried + /// The method name that was called + public UnboundedQueryException(string entityType, string methodName) + : base($"Unbounded query attempted on {entityType} via {methodName}(). " + + $"Use GetPaginatedAsync() or GetAllUnboundedAsync() for explicit batch needs.") + { + EntityType = entityType; + MethodName = methodName; + } + + /// + /// Initializes a new instance of the UnboundedQueryException class with an inner exception. + /// + /// The entity type being queried + /// The method name that was called + /// The inner exception + public UnboundedQueryException(string entityType, string methodName, Exception innerException) + : base($"Unbounded query attempted on {entityType} via {methodName}(). " + + $"Use GetPaginatedAsync() or GetAllUnboundedAsync() for explicit batch needs.", innerException) + { + EntityType = entityType; + MethodName = methodName; + } + } +} diff --git a/Shared/ConduitLLM.Configuration/Extensions/DeprecationWarnings.cs b/Shared/ConduitLLM.Configuration/Extensions/DeprecationWarnings.cs index 15e89f95d..643a72969 100644 --- a/Shared/ConduitLLM.Configuration/Extensions/DeprecationWarnings.cs +++ b/Shared/ConduitLLM.Configuration/Extensions/DeprecationWarnings.cs @@ -84,7 +84,7 @@ public static void LogEnvironmentVariableDeprecations(ILogger logger) deprecatedVars.Add("AdminApi__MasterKey (use CONDUIT_API_TO_API_BACKEND_AUTH_KEY)"); } - if (deprecatedVars.Count() == 0) + if (!deprecatedVars.Any()) { return null; } diff --git a/Shared/ConduitLLM.Configuration/Extensions/RepositoryPaginationExtensions.cs b/Shared/ConduitLLM.Configuration/Extensions/RepositoryPaginationExtensions.cs new file mode 100644 index 000000000..8f39f6007 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Extensions/RepositoryPaginationExtensions.cs @@ -0,0 +1,251 @@ +namespace ConduitLLM.Configuration.Extensions +{ + /// + /// Extension methods for working with paginated repository methods. + /// + /// + /// + /// These helpers assist in migrating from deprecated GetAllAsync methods to paginated alternatives. + /// Use these methods when you genuinely need all records from a repository. + /// + /// + /// For UI/API scenarios, prefer exposing pagination parameters to the caller instead of fetching all records. + /// + /// + public static class RepositoryPaginationExtensions + { + /// + /// Default page size used when iterating through all pages. + /// + public const int DefaultPageSize = 100; + + /// + /// Retrieves all items from a paginated repository method by iterating through all pages. + /// + /// The entity type. + /// + /// A function that takes (pageNumber, pageSize, cancellationToken) and returns a paginated result. + /// + /// The number of items to fetch per page. Defaults to 100. + /// A token to cancel the asynchronous operation. + /// A list containing all items from all pages. + /// + /// + /// This method is intended for batch processing scenarios where all records are genuinely needed, + /// such as maintenance jobs, exports, or migration scripts. + /// + /// + /// For large datasets, consider: + /// + /// Using a streaming/yield approach if processing one at a time + /// Adding database-level filtering to reduce the result set + /// Processing in batches with to get page-by-page access + /// + /// + /// + /// + /// + /// // Migrate from: var all = await _repo.GetAllAsync(ct); + /// // To: + /// var all = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + /// _repo.GetPaginatedAsync, cancellationToken: ct); + /// + /// + public static async Task> GetAllViaPaginationAsync( + Func Items, int TotalCount)>> paginatedMethod, + int pageSize = DefaultPageSize, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(paginatedMethod); + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero."); + } + + var allItems = new List(); + int page = 1; + int totalCount; + + do + { + cancellationToken.ThrowIfCancellationRequested(); + + var (items, total) = await paginatedMethod(page, pageSize, cancellationToken).ConfigureAwait(false); + totalCount = total; + + if (items.Count > 0) + { + allItems.AddRange(items); + } + + page++; + } + while (allItems.Count < totalCount); + + return allItems; + } + + /// + /// Retrieves all items using a parameterized paginated method (e.g., GetByProviderIdPaginatedAsync). + /// + /// The type of the filter parameter. + /// The entity type. + /// + /// A function that takes (param, pageNumber, pageSize, cancellationToken) and returns a paginated result. + /// + /// The filter parameter to pass to the paginated method. + /// The number of items to fetch per page. Defaults to 100. + /// A token to cancel the asynchronous operation. + /// A list containing all items from all pages. + /// + /// + /// // Migrate from: var keys = await _repo.GetByProviderIdAsync(providerId); + /// // To: + /// var keys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + /// _repo.GetByProviderIdPaginatedAsync, providerId, cancellationToken: ct); + /// + /// + public static async Task> GetAllViaPaginationAsync( + Func Items, int TotalCount)>> paginatedMethod, + TParam param, + int pageSize = DefaultPageSize, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(paginatedMethod); + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero."); + } + + var allItems = new List(); + int page = 1; + int totalCount; + + do + { + cancellationToken.ThrowIfCancellationRequested(); + + var (items, total) = await paginatedMethod(param, page, pageSize, cancellationToken).ConfigureAwait(false); + totalCount = total; + + if (items.Count > 0) + { + allItems.AddRange(items); + } + + page++; + } + while (allItems.Count < totalCount); + + return allItems; + } + + /// + /// Iterates through all pages of a paginated repository method, yielding each page. + /// + /// The entity type. + /// + /// A function that takes (pageNumber, pageSize, cancellationToken) and returns a paginated result. + /// + /// The number of items to fetch per page. Defaults to 100. + /// A token to cancel the asynchronous operation. + /// An async enumerable of pages, where each page contains a list of items. + /// + /// Use this method when you want to process records in batches without loading all into memory at once. + /// + /// + /// + /// await foreach (var page in RepositoryPaginationExtensions.GetAllPagesAsync( + /// _repo.GetPaginatedAsync, cancellationToken: ct)) + /// { + /// foreach (var item in page) + /// { + /// // Process each item + /// } + /// } + /// + /// + public static async IAsyncEnumerable> GetAllPagesAsync( + Func Items, int TotalCount)>> paginatedMethod, + int pageSize = DefaultPageSize, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(paginatedMethod); + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero."); + } + + int page = 1; + int fetchedCount = 0; + int totalCount; + + do + { + cancellationToken.ThrowIfCancellationRequested(); + + var (items, total) = await paginatedMethod(page, pageSize, cancellationToken).ConfigureAwait(false); + totalCount = total; + + if (items.Count > 0) + { + fetchedCount += items.Count; + yield return items; + } + + page++; + } + while (fetchedCount < totalCount); + } + + /// + /// Iterates through all pages of a parameterized paginated repository method, yielding each page. + /// + /// The type of the filter parameter. + /// The entity type. + /// + /// A function that takes (param, pageNumber, pageSize, cancellationToken) and returns a paginated result. + /// + /// The filter parameter to pass to the paginated method. + /// The number of items to fetch per page. Defaults to 100. + /// A token to cancel the asynchronous operation. + /// An async enumerable of pages, where each page contains a list of items. + public static async IAsyncEnumerable> GetAllPagesAsync( + Func Items, int TotalCount)>> paginatedMethod, + TParam param, + int pageSize = DefaultPageSize, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(paginatedMethod); + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero."); + } + + int page = 1; + int fetchedCount = 0; + int totalCount; + + do + { + cancellationToken.ThrowIfCancellationRequested(); + + var (items, total) = await paginatedMethod(param, page, pageSize, cancellationToken).ConfigureAwait(false); + totalCount = total; + + if (items.Count > 0) + { + fetchedCount += items.Count; + yield return items; + } + + page++; + } + while (fetchedCount < totalCount); + } + } +} diff --git a/Shared/ConduitLLM.Configuration/Extensions/ServiceCollectionExtensions.cs b/Shared/ConduitLLM.Configuration/Extensions/ServiceCollectionExtensions.cs index a52558596..b627eb4c6 100644 --- a/Shared/ConduitLLM.Configuration/Extensions/ServiceCollectionExtensions.cs +++ b/Shared/ConduitLLM.Configuration/Extensions/ServiceCollectionExtensions.cs @@ -61,9 +61,6 @@ public static IServiceCollection AddRepositories(this IServiceCollection service services.AddScoped(); services.AddScoped(); - // Register cache configuration service - services.AddScoped(); - return services; } diff --git a/Shared/ConduitLLM.Configuration/HealthChecks/RabbitMqHealthCheck.cs b/Shared/ConduitLLM.Configuration/HealthChecks/RabbitMqHealthCheck.cs deleted file mode 100644 index 0b2cd0da2..000000000 --- a/Shared/ConduitLLM.Configuration/HealthChecks/RabbitMqHealthCheck.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; - -using RabbitMQ.Client; - -namespace ConduitLLM.Configuration.HealthChecks -{ - /// - /// Health check for RabbitMQ connectivity. - /// - public class RabbitMqHealthCheck : IHealthCheck - { - private readonly RabbitMqConfiguration _configuration; - - /// - /// Initializes a new instance of the class. - /// - /// The RabbitMQ configuration. - public RabbitMqHealthCheck(RabbitMqConfiguration configuration) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - } - - /// - /// Runs the health check, returning the status of the RabbitMQ connection. - /// - /// A context object associated with the current execution. - /// A cancellation token that can be used to cancel the health check. - /// A task that represents the asynchronous health check operation. - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - try - { - var factory = new ConnectionFactory - { - HostName = _configuration.Host, - Port = _configuration.Port, - UserName = _configuration.Username, - Password = _configuration.Password, - VirtualHost = _configuration.VHost, - RequestedHeartbeat = TimeSpan.FromSeconds(_configuration.HeartbeatInterval), - NetworkRecoveryInterval = TimeSpan.FromSeconds(_configuration.NetworkRecoveryInterval), - AutomaticRecoveryEnabled = _configuration.AutomaticRecoveryEnabled - }; - - using var connection = await factory.CreateConnectionAsync(); - // RabbitMQ.Client v7.x uses CreateChannel() instead of CreateModel() - using var channel = await connection.CreateChannelAsync(); - - // Just verify we can create a channel successfully - // Don't check for specific exchanges as they may not exist on first startup - - return await Task.FromResult(HealthCheckResult.Healthy( - $"RabbitMQ connection established to {_configuration.Host}:{_configuration.Port}")); - } - catch (Exception ex) - { - return await Task.FromResult(HealthCheckResult.Unhealthy( - $"RabbitMQ connection failed to {_configuration.Host}:{_configuration.Port}", - exception: ex)); - } - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Interceptors/QueryMonitoringInterceptor.cs b/Shared/ConduitLLM.Configuration/Interceptors/QueryMonitoringInterceptor.cs new file mode 100644 index 000000000..169d9e229 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Interceptors/QueryMonitoringInterceptor.cs @@ -0,0 +1,207 @@ +using System.Data.Common; +using System.Diagnostics; + +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Prometheus; + +namespace ConduitLLM.Configuration.Interceptors; + +/// +/// EF Core interceptor that monitors query execution for performance issues. +/// Logs warnings for slow queries and large result sets. +/// +public class QueryMonitoringInterceptor : DbCommandInterceptor +{ + private readonly ILogger _logger; + private readonly QueryMonitoringOptions _options; + + // Prometheus metrics for query monitoring + private static readonly Counter QueryExecutions = Prometheus.Metrics + .CreateCounter("conduit_db_query_executions_total", "Total database query executions", + new CounterConfiguration + { + LabelNames = new[] { "type" } // type: select, non_query, scalar + }); + + private static readonly Histogram QueryDuration = Prometheus.Metrics + .CreateHistogram("conduit_db_query_duration_seconds", "Database query duration", + new HistogramConfiguration + { + LabelNames = new[] { "type" }, + Buckets = Histogram.ExponentialBuckets(0.001, 2, 14) // 1ms to ~16s + }); + + private static readonly Counter SlowQueries = Prometheus.Metrics + .CreateCounter("conduit_db_slow_queries_total", "Total slow database queries", + new CounterConfiguration + { + LabelNames = new[] { "type" } + }); + + /// + /// Creates a new instance of the QueryMonitoringInterceptor. + /// + /// The logger instance + /// The monitoring options + public QueryMonitoringInterceptor( + ILogger logger, + IOptions options) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public override DbDataReader ReaderExecuted( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result) + { + if (!_options.Enabled) + { + return result; + } + + RecordQueryMetrics(command, eventData, IsSelectQuery(command) ? "select" : "non_query"); + + // Only wrap SELECT queries - INSERT/UPDATE/DELETE with RETURNING clauses + // return readers that Npgsql internally casts to NpgsqlDataReader, which fails + // if wrapped. Row counting is only meaningful for SELECT anyway. + if (IsSelectQuery(command)) + { + return new RowCountingDataReader(result, _logger, _options, GetCommandSummary(command)); + } + + return result; + } + + /// + public override async ValueTask ReaderExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + if (!_options.Enabled) + { + return result; + } + + RecordQueryMetrics(command, eventData, IsSelectQuery(command) ? "select" : "non_query"); + + // Only wrap SELECT queries - INSERT/UPDATE/DELETE with RETURNING clauses + // return readers that Npgsql internally casts to NpgsqlDataReader, which fails + // if wrapped. Row counting is only meaningful for SELECT anyway. + if (IsSelectQuery(command)) + { + return new RowCountingDataReader(result, _logger, _options, GetCommandSummary(command)); + } + + return result; + } + + /// + public override int NonQueryExecuted( + DbCommand command, + CommandExecutedEventData eventData, + int result) + { + if (_options.Enabled) + { + RecordQueryMetrics(command, eventData, "non_query"); + } + + return result; + } + + /// + public override async ValueTask NonQueryExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + if (_options.Enabled) + { + RecordQueryMetrics(command, eventData, "non_query"); + } + + return result; + } + + /// + public override object? ScalarExecuted( + DbCommand command, + CommandExecutedEventData eventData, + object? result) + { + if (_options.Enabled) + { + RecordQueryMetrics(command, eventData, "scalar"); + } + + return result; + } + + /// + public override async ValueTask ScalarExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + object? result, + CancellationToken cancellationToken = default) + { + if (_options.Enabled) + { + RecordQueryMetrics(command, eventData, "scalar"); + } + + return result; + } + + private void RecordQueryMetrics(DbCommand command, CommandExecutedEventData eventData, string queryType) + { + var durationSeconds = eventData.Duration.TotalSeconds; + QueryExecutions.WithLabels(queryType).Inc(); + QueryDuration.WithLabels(queryType).Observe(durationSeconds); + + var durationMs = eventData.Duration.TotalMilliseconds; + if (durationMs >= _options.SlowQueryThresholdMs) + { + SlowQueries.WithLabels(queryType).Inc(); + var commandSummary = GetCommandSummary(command); + _logger.LogWarning( + "Slow query detected ({DurationMs:F1}ms, threshold: {ThresholdMs}ms). Command: {CommandSummary}", + durationMs, + _options.SlowQueryThresholdMs, + commandSummary); + } + } + + private static bool IsSelectQuery(DbCommand command) + { + var text = command.CommandText.TrimStart(); + return text.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase); + } + + private string GetCommandSummary(DbCommand command) + { + if (_options.LogFullCommand) + { + return command.CommandText; + } + + // Extract just the first part of the command (SELECT, INSERT, etc.) and table name + var text = command.CommandText; + var lines = text.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length > 0) + { + var firstLine = lines[0].Trim(); + // Limit length for summary + return firstLine.Length > 100 ? firstLine[..100] + "..." : firstLine; + } + + return "[empty command]"; + } +} diff --git a/Shared/ConduitLLM.Configuration/Interceptors/QueryMonitoringOptions.cs b/Shared/ConduitLLM.Configuration/Interceptors/QueryMonitoringOptions.cs new file mode 100644 index 000000000..6d79ef841 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Interceptors/QueryMonitoringOptions.cs @@ -0,0 +1,35 @@ +namespace ConduitLLM.Configuration.Interceptors; + +/// +/// Configuration options for query monitoring and performance tracking. +/// +public class QueryMonitoringOptions +{ + /// + /// The configuration section name for binding. + /// + public const string SectionName = "QueryMonitoring"; + + /// + /// Gets or sets whether query monitoring is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the threshold in milliseconds for logging slow queries. + /// Queries exceeding this threshold will be logged as warnings. + /// + public int SlowQueryThresholdMs { get; set; } = 5000; + + /// + /// Gets or sets the threshold for logging large result sets. + /// Result sets exceeding this row count will be logged as warnings. + /// + public int LargeResultSetThreshold { get; set; } = 1000; + + /// + /// Gets or sets whether to include the full SQL command in log messages. + /// This can be useful for debugging but may expose sensitive data. + /// + public bool LogFullCommand { get; set; } = false; +} diff --git a/Shared/ConduitLLM.Configuration/Interceptors/RowCountingDataReader.cs b/Shared/ConduitLLM.Configuration/Interceptors/RowCountingDataReader.cs new file mode 100644 index 000000000..f3af90b41 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Interceptors/RowCountingDataReader.cs @@ -0,0 +1,219 @@ +using System.Collections; +using System.Data; +using System.Data.Common; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Configuration.Interceptors; + +/// +/// Wrapper around DbDataReader that counts rows as they are read. +/// Logs a warning when the row count exceeds the configured threshold. +/// +public class RowCountingDataReader : DbDataReader +{ + private readonly DbDataReader _innerReader; + private readonly ILogger _logger; + private readonly QueryMonitoringOptions _options; + private readonly string _commandSummary; + private int _rowCount; + private bool _warningLogged; + + /// + /// Creates a new instance of the RowCountingDataReader. + /// + /// The underlying data reader + /// The logger instance + /// The monitoring options + /// Summary of the command for logging + public RowCountingDataReader( + DbDataReader innerReader, + ILogger logger, + QueryMonitoringOptions options, + string commandSummary) + { + _innerReader = innerReader ?? throw new ArgumentNullException(nameof(innerReader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _commandSummary = commandSummary; + } + + /// + public override bool Read() + { + var result = _innerReader.Read(); + if (result) + { + _rowCount++; + CheckThreshold(); + } + return result; + } + + /// + public override async Task ReadAsync(CancellationToken cancellationToken) + { + var result = await _innerReader.ReadAsync(cancellationToken); + if (result) + { + _rowCount++; + CheckThreshold(); + } + return result; + } + + private void CheckThreshold() + { + if (!_warningLogged && _rowCount == _options.LargeResultSetThreshold) + { + _warningLogged = true; + _logger.LogWarning( + "Large result set detected ({RowCount}+ rows, threshold: {ThresholdRows}). " + + "Consider using pagination. Command: {CommandSummary}", + _rowCount, + _options.LargeResultSetThreshold, + _commandSummary); + } + } + + // Delegate all other properties and methods to the inner reader + + /// + public override int Depth => _innerReader.Depth; + + /// + public override int FieldCount => _innerReader.FieldCount; + + /// + public override bool HasRows => _innerReader.HasRows; + + /// + public override bool IsClosed => _innerReader.IsClosed; + + /// + public override int RecordsAffected => _innerReader.RecordsAffected; + + /// + public override object this[int ordinal] => _innerReader[ordinal]; + + /// + public override object this[string name] => _innerReader[name]; + + /// + public override bool GetBoolean(int ordinal) => _innerReader.GetBoolean(ordinal); + + /// + public override byte GetByte(int ordinal) => _innerReader.GetByte(ordinal); + + /// + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) + => _innerReader.GetBytes(ordinal, dataOffset, buffer, bufferOffset, length); + + /// + public override char GetChar(int ordinal) => _innerReader.GetChar(ordinal); + + /// + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) + => _innerReader.GetChars(ordinal, dataOffset, buffer, bufferOffset, length); + + /// + public override string GetDataTypeName(int ordinal) => _innerReader.GetDataTypeName(ordinal); + + /// + public override DateTime GetDateTime(int ordinal) => _innerReader.GetDateTime(ordinal); + + /// + public override decimal GetDecimal(int ordinal) => _innerReader.GetDecimal(ordinal); + + /// + public override double GetDouble(int ordinal) => _innerReader.GetDouble(ordinal); + + /// + public override Type GetFieldType(int ordinal) => _innerReader.GetFieldType(ordinal); + + /// + public override float GetFloat(int ordinal) => _innerReader.GetFloat(ordinal); + + /// + public override Guid GetGuid(int ordinal) => _innerReader.GetGuid(ordinal); + + /// + public override short GetInt16(int ordinal) => _innerReader.GetInt16(ordinal); + + /// + public override int GetInt32(int ordinal) => _innerReader.GetInt32(ordinal); + + /// + public override long GetInt64(int ordinal) => _innerReader.GetInt64(ordinal); + + /// + public override string GetName(int ordinal) => _innerReader.GetName(ordinal); + + /// + public override int GetOrdinal(string name) => _innerReader.GetOrdinal(name); + + /// + public override string GetString(int ordinal) => _innerReader.GetString(ordinal); + + /// + public override object GetValue(int ordinal) => _innerReader.GetValue(ordinal); + + /// + public override int GetValues(object[] values) => _innerReader.GetValues(values); + + /// + public override bool IsDBNull(int ordinal) => _innerReader.IsDBNull(ordinal); + + /// + public override Task IsDBNullAsync(int ordinal, CancellationToken cancellationToken) + => _innerReader.IsDBNullAsync(ordinal, cancellationToken); + + /// + public override bool NextResult() => _innerReader.NextResult(); + + /// + public override Task NextResultAsync(CancellationToken cancellationToken) + => _innerReader.NextResultAsync(cancellationToken); + + /// + public override IEnumerator GetEnumerator() => _innerReader.GetEnumerator(); + + /// + public override DataTable? GetSchemaTable() => _innerReader.GetSchemaTable(); + + /// + public override void Close() => _innerReader.Close(); + + /// + public override Task CloseAsync() => _innerReader.CloseAsync(); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _innerReader.Dispose(); + } + base.Dispose(disposing); + } + + /// + public override async ValueTask DisposeAsync() + { + await _innerReader.DisposeAsync(); + await base.DisposeAsync(); + } + + /// + public override T GetFieldValue(int ordinal) => _innerReader.GetFieldValue(ordinal); + + /// + public override Task GetFieldValueAsync(int ordinal, CancellationToken cancellationToken) + => _innerReader.GetFieldValueAsync(ordinal, cancellationToken); + + /// + public override Stream GetStream(int ordinal) => _innerReader.GetStream(ordinal); + + /// + public override TextReader GetTextReader(int ordinal) => _innerReader.GetTextReader(ordinal); +} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IAsyncTaskRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IAsyncTaskRepository.cs index 875fc56cb..31ccc9f95 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IAsyncTaskRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IAsyncTaskRepository.cs @@ -4,17 +4,10 @@ namespace ConduitLLM.Configuration.Interfaces { /// /// Repository interface for managing async tasks. + /// Extends IRepositoryBase for standard CRUD operations. /// - public interface IAsyncTaskRepository + public interface IAsyncTaskRepository : IRepositoryBase { - /// - /// Gets a task by its ID. - /// - /// The task ID. - /// Cancellation token. - /// The task if found, null otherwise. - Task GetByIdAsync(string taskId, CancellationToken cancellationToken = default); - /// /// Gets all tasks for a virtual key. /// @@ -31,30 +24,6 @@ public interface IAsyncTaskRepository /// List of active tasks for the virtual key. Task> GetActiveByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default); - /// - /// Creates a new async task. - /// - /// The task to create. - /// Cancellation token. - /// The created task ID. - Task CreateAsync(AsyncTask task, CancellationToken cancellationToken = default); - - /// - /// Updates an existing async task. - /// - /// The task to update. - /// Cancellation token. - /// True if updated successfully, false otherwise. - Task UpdateAsync(AsyncTask task, CancellationToken cancellationToken = default); - - /// - /// Deletes a task by its ID. - /// - /// The task ID. - /// Cancellation token. - /// True if deleted successfully, false otherwise. - Task DeleteAsync(string taskId, CancellationToken cancellationToken = default); - /// /// Archives completed tasks older than the specified timespan. /// diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IBatchSpendUpdateService.cs b/Shared/ConduitLLM.Configuration/Interfaces/IBatchSpendUpdateService.cs index 96f816115..3aa3c8ac8 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IBatchSpendUpdateService.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IBatchSpendUpdateService.cs @@ -16,8 +16,16 @@ public interface IBatchSpendUpdateService event Action? SpendUpdatesCompleted; /// - /// Queues a spend update to be processed in the next batch + /// Queues a spend update to Redis for batch processing. + /// Throws on failure so the caller can fall back to alternative paths. /// - void QueueSpendUpdate(int virtualKeyId, decimal cost); + Task QueueSpendUpdateAsync(int virtualKeyId, decimal cost); + + /// + /// Queues a spend update to an in-memory fallback queue. + /// Used as a last resort when both Redis and direct DB writes fail. + /// Updates are drained on the next successful flush cycle. + /// + void QueueFallbackUpdate(int virtualKeyId, decimal cost); } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingRepository.cs index 96ff248ea..b4addb23d 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingRepository.cs @@ -1,75 +1,36 @@ using ConduitLLM.Configuration.Entities; -namespace ConduitLLM.Configuration.Interfaces +namespace ConduitLLM.Configuration.Interfaces; + +/// +/// Repository interface for managing global settings. +/// Inherits standard CRUD operations from IRepositoryBase. +/// +public interface IGlobalSettingRepository : IRepositoryBase { /// - /// Repository interface for managing global settings + /// Gets a global setting by key. /// - public interface IGlobalSettingRepository - { - /// - /// Gets a global setting by ID - /// - /// The global setting ID - /// Cancellation token - /// The global setting entity or null if not found - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - - /// - /// Gets a global setting by key - /// - /// The setting key - /// Cancellation token - /// The global setting entity or null if not found - Task GetByKeyAsync(string key, CancellationToken cancellationToken = default); - - /// - /// Gets all global settings - /// - /// Cancellation token - /// A list of all global settings - Task> GetAllAsync(CancellationToken cancellationToken = default); - - /// - /// Creates a new global setting - /// - /// The global setting to create - /// Cancellation token - /// The ID of the created global setting - Task CreateAsync(GlobalSetting globalSetting, CancellationToken cancellationToken = default); - - /// - /// Updates a global setting - /// - /// The global setting to update - /// Cancellation token - /// True if the update was successful, false otherwise - Task UpdateAsync(GlobalSetting globalSetting, CancellationToken cancellationToken = default); + /// The setting key + /// Cancellation token + /// The global setting entity or null if not found + Task GetByKeyAsync(string key, CancellationToken cancellationToken = default); - /// - /// Updates or creates a global setting - /// - /// The setting key - /// The setting value - /// Optional description - /// Cancellation token - /// True if the operation was successful, false otherwise - Task UpsertAsync(string key, string value, string? description = null, CancellationToken cancellationToken = default); - - /// - /// Deletes a global setting - /// - /// The ID of the global setting to delete - /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// + /// Updates or creates a global setting. + /// + /// The setting key + /// The setting value + /// Optional description + /// Cancellation token + /// True if the operation was successful, false otherwise + Task UpsertAsync(string key, string value, string? description = null, CancellationToken cancellationToken = default); - /// - /// Deletes a global setting by key - /// - /// The key of the global setting to delete - /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteByKeyAsync(string key, CancellationToken cancellationToken = default); - } + /// + /// Deletes a global setting by key. + /// + /// The key of the global setting to delete + /// Cancellation token + /// True if the deletion was successful, false otherwise + Task DeleteByKeyAsync(string key, CancellationToken cancellationToken = default); } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingsCacheService.cs b/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingsCacheService.cs index 367ccdc61..2a3645c93 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingsCacheService.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IGlobalSettingsCacheService.cs @@ -46,6 +46,14 @@ public interface IGlobalSettingsCacheService /// Task GetLLMCachingEnabledAsync(); + /// + /// Gets a raw setting value by key from the cache. + /// Returns null if the key does not exist. + /// + /// The setting key to retrieve. + /// The setting value, or null if not found. + Task GetSettingValueAsync(string key); + /// /// Invalidates a specific cached setting, forcing it to be reloaded from the database on next access. /// diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IIpFilterRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IIpFilterRepository.cs index bad460aaf..2eac66d78 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IIpFilterRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IIpFilterRepository.cs @@ -3,47 +3,23 @@ namespace ConduitLLM.Configuration.Interfaces; /// -/// Repository interface for managing IP filters +/// Repository interface for managing IP filters. +/// Inherits standard CRUD operations from IRepositoryBase. /// -public interface IIpFilterRepository +public interface IIpFilterRepository : IRepositoryBase { /// - /// Gets all IP filters - /// - /// A collection of IP filters - Task> GetAllAsync(); - - /// - /// Gets all enabled IP filters + /// Gets all enabled IP filters ordered by filter type and IP address. /// + /// Cancellation token /// A collection of enabled IP filters - Task> GetEnabledAsync(); - - /// - /// Gets an IP filter by ID - /// - /// The ID of the filter to get - /// The IP filter entity if found, null otherwise - Task GetByIdAsync(int id); + Task> GetEnabledAsync(CancellationToken cancellationToken = default); /// - /// Adds a new IP filter + /// Adds a new IP filter and returns the created entity. /// /// The filter to add + /// Cancellation token /// The added filter with generated ID - Task AddAsync(IpFilterEntity filter); - - /// - /// Updates an existing IP filter - /// - /// The filter to update - /// True if the filter was updated, false if not found - Task UpdateAsync(IpFilterEntity filter); - - /// - /// Deletes an IP filter by ID - /// - /// The ID of the filter to delete - /// True if the filter was deleted, false if not found - Task DeleteAsync(int id); + Task AddAsync(IpFilterEntity filter, CancellationToken cancellationToken = default); } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IMediaRecordRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IMediaRecordRepository.cs index 5e1489744..411fd2d2f 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IMediaRecordRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IMediaRecordRepository.cs @@ -4,102 +4,102 @@ namespace ConduitLLM.Configuration.Interfaces { /// /// Repository interface for media record operations. + /// Extends IRepositoryBase for standard CRUD operations and adds domain-specific methods. /// - public interface IMediaRecordRepository + public interface IMediaRecordRepository : IRepositoryBase { - /// - /// Creates a new media record. - /// - /// The media record to create. - /// The created media record. - Task CreateAsync(MediaRecord mediaRecord); - - /// - /// Gets a media record by its ID. - /// - /// The ID of the media record. - /// The media record if found, null otherwise. - Task GetByIdAsync(Guid id); - /// /// Gets a media record by its storage key. /// /// The storage key of the media record. + /// Cancellation token. /// The media record if found, null otherwise. - Task GetByStorageKeyAsync(string storageKey); + Task GetByStorageKeyAsync(string storageKey, CancellationToken cancellationToken = default); /// /// Gets all media records for a virtual key. /// /// The ID of the virtual key. - /// List of media records for the virtual key. - Task> GetByVirtualKeyIdAsync(int virtualKeyId); + /// Cancellation token. + /// List of media records for the virtual key ordered by created date descending. + Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default); /// /// Gets media records that have expired. /// /// The current time to compare against. + /// Cancellation token. /// List of expired media records. - Task> GetExpiredMediaAsync(DateTime currentTime); + Task> GetExpiredMediaAsync(DateTime currentTime, CancellationToken cancellationToken = default); /// /// Gets media records older than a specified date. /// /// The cutoff date. + /// Cancellation token. /// List of old media records. - Task> GetMediaOlderThanAsync(DateTime cutoffDate); + Task> GetMediaOlderThanAsync(DateTime cutoffDate, CancellationToken cancellationToken = default); /// /// Gets orphaned media records (where virtual key no longer exists). /// + /// Cancellation token. /// List of orphaned media records. - Task> GetOrphanedMediaAsync(); + Task> GetOrphanedMediaAsync(CancellationToken cancellationToken = default); /// /// Updates access statistics for a media record. /// /// The ID of the media record. + /// Cancellation token. /// True if updated successfully, false otherwise. - Task UpdateAccessStatsAsync(Guid id); - - /// - /// Deletes a media record. - /// - /// The ID of the media record to delete. - /// True if deleted successfully, false otherwise. - Task DeleteAsync(Guid id); + Task UpdateAccessStatsAsync(Guid id, CancellationToken cancellationToken = default); /// /// Deletes multiple media records. /// /// The IDs of the media records to delete. + /// Cancellation token. /// Number of records deleted. - Task DeleteManyAsync(IEnumerable ids); + Task DeleteManyAsync(IEnumerable ids, CancellationToken cancellationToken = default); /// /// Gets the total storage size used by a virtual key. /// /// The ID of the virtual key. + /// Cancellation token. /// Total storage size in bytes. - Task GetTotalStorageSizeByVirtualKeyAsync(int virtualKeyId); + Task GetTotalStorageSizeByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default); /// /// Gets storage statistics grouped by provider. /// + /// Cancellation token. /// Dictionary of provider names to total storage size. - Task> GetStorageStatsByProviderAsync(); + Task> GetStorageStatsByProviderAsync(CancellationToken cancellationToken = default); /// /// Gets storage statistics grouped by media type. /// + /// Cancellation token. /// Dictionary of media types to total storage size. - Task> GetStorageStatsByMediaTypeAsync(); + Task> GetStorageStatsByMediaTypeAsync(CancellationToken cancellationToken = default); /// /// Gets the count of media records for a virtual key. /// /// The ID of the virtual key. + /// Cancellation token. /// Count of media records. - Task GetCountByVirtualKeyAsync(int virtualKeyId); + Task GetCountByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default); + + /// + /// Searches for media records by storage key pattern using database-level filtering. + /// + /// The pattern to match against storage keys (case-insensitive contains). + /// Maximum number of results to return. Defaults to 100. + /// Cancellation token. + /// List of matching media records ordered by created date descending. + Task> SearchByStorageKeyPatternAsync(string storageKeyPattern, int maxResults = 100, CancellationToken cancellationToken = default); } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IModelAuthorRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IModelAuthorRepository.cs new file mode 100644 index 000000000..ecf00477b --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Interfaces/IModelAuthorRepository.cs @@ -0,0 +1,26 @@ +using ConduitLLM.Configuration.Entities; + +namespace ConduitLLM.Configuration.Interfaces; + +/// +/// Repository interface for managing model authors. +/// Inherits standard CRUD operations from IRepositoryBase. +/// +public interface IModelAuthorRepository : IRepositoryBase +{ + /// + /// Gets a model author by name. + /// + /// The name of the model author + /// Cancellation token + /// The model author if found, null otherwise + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Gets all model series by a specific author. + /// + /// The ID of the author + /// Cancellation token + /// A list of model series if author exists, null if author not found + Task?> GetSeriesByAuthorAsync(int authorId, CancellationToken cancellationToken = default); +} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IModelCostRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IModelCostRepository.cs index af341f21e..47686e341 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IModelCostRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IModelCostRepository.cs @@ -3,18 +3,11 @@ namespace ConduitLLM.Configuration.Interfaces { /// - /// Repository interface for managing model costs + /// Repository interface for managing model costs. + /// Extends IRepositoryBase for standard CRUD operations. /// - public interface IModelCostRepository + public interface IModelCostRepository : IRepositoryBase { - /// - /// Gets a model cost by ID - /// - /// The model cost ID - /// Cancellation token - /// The model cost entity or null if not found - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - /// /// Gets a model cost by cost name /// @@ -23,44 +16,28 @@ public interface IModelCostRepository /// The model cost entity or null if not found Task GetByCostNameAsync(string costName, CancellationToken cancellationToken = default); - - /// - /// Gets all model costs - /// - /// Cancellation token - /// A list of all model costs - Task> GetAllAsync(CancellationToken cancellationToken = default); - /// /// Gets all model costs associated with a specific provider /// /// The provider ID to filter by /// Cancellation token /// List of model costs for the specified provider + /// This method is obsolete. Use GetByProviderPaginatedAsync instead for better performance. + [Obsolete("Use GetByProviderPaginatedAsync instead. This method loads all records into memory and will be removed in a future version.")] Task> GetByProviderAsync(int providerId, CancellationToken cancellationToken = default); /// - /// Creates a new model cost + /// Gets model costs for a specific provider with pagination /// - /// The model cost to create - /// Cancellation token - /// The ID of the created model cost - Task CreateAsync(ModelCost modelCost, CancellationToken cancellationToken = default); - - /// - /// Updates a model cost - /// - /// The model cost to update - /// Cancellation token - /// True if the update was successful, false otherwise - Task UpdateAsync(ModelCost modelCost, CancellationToken cancellationToken = default); - - /// - /// Deletes a model cost - /// - /// The ID of the model cost to delete + /// The provider ID to filter by + /// The page number (1-based) + /// The number of items per page /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// A tuple with the list of model costs and the total count + Task<(List Items, int TotalCount)> GetByProviderPaginatedAsync( + int providerId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); } } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IModelCostService.cs b/Shared/ConduitLLM.Configuration/Interfaces/IModelCostService.cs index dece5a248..fd33f200b 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IModelCostService.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IModelCostService.cs @@ -62,7 +62,9 @@ public interface IModelCostService Task DeleteModelCostAsync(int id, CancellationToken cancellationToken = default); /// - /// Clears the cache for model costs + /// Clears the cache for model costs asynchronously. /// - void ClearCache(); + /// Cancellation token + /// A task representing the asynchronous operation + Task ClearCacheAsync(CancellationToken cancellationToken = default); } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IModelProviderMappingRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IModelProviderMappingRepository.cs index 4cbc0aaa2..1e42e1121 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IModelProviderMappingRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IModelProviderMappingRepository.cs @@ -13,7 +13,7 @@ namespace ConduitLLM.Configuration.Interfaces /// Key features of this repository include: /// /// - /// CRUD operations for model provider mapping entities + /// CRUD operations for model provider mapping entities (inherited from IRepositoryBase) /// Lookup by model alias to find the appropriate provider and model /// Filtering by provider to get all mappings for a specific provider /// @@ -22,16 +22,8 @@ namespace ConduitLLM.Configuration.Interfaces /// and providing a clean, domain-focused API for model mapping management. /// /// - public interface IModelProviderMappingRepository + public interface IModelProviderMappingRepository : IRepositoryBase { - /// - /// Gets a model provider mapping by ID - /// - /// The model provider mapping ID - /// Cancellation token - /// The model provider mapping entity or null if not found - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - /// /// Gets a model provider mapping by model alias /// @@ -40,43 +32,36 @@ public interface IModelProviderMappingRepository /// The model provider mapping entity or null if not found Task GetByModelNameAsync(string modelName, CancellationToken cancellationToken = default); - /// - /// Gets all model provider mappings - /// - /// Cancellation token - /// A list of all model provider mappings - Task> GetAllAsync(CancellationToken cancellationToken = default); - /// /// Gets all model provider mappings for a specific provider /// /// The provider type /// Cancellation token /// A list of model provider mappings for the specified provider + /// This method is obsolete. Use GetByProviderPaginatedAsync instead for better performance. + [Obsolete("Use GetByProviderPaginatedAsync instead. This method loads all records into memory and will be removed in a future version.")] Task> GetByProviderAsync(ProviderType providerType, CancellationToken cancellationToken = default); /// - /// Creates a new model provider mapping - /// - /// The model provider mapping to create - /// Cancellation token - /// The ID of the created model provider mapping - Task CreateAsync(Entities.ModelProviderMapping modelProviderMapping, CancellationToken cancellationToken = default); - - /// - /// Updates a model provider mapping + /// Gets model provider mappings for a specific provider with pagination /// - /// The model provider mapping to update + /// The provider ID + /// The page number (1-based) + /// The number of items per page /// Cancellation token - /// True if the update was successful, false otherwise - Task UpdateAsync(Entities.ModelProviderMapping modelProviderMapping, CancellationToken cancellationToken = default); + /// A tuple with the list of mappings and the total count + Task<(List Items, int TotalCount)> GetByProviderPaginatedAsync( + int providerId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); /// - /// Deletes a model provider mapping + /// Gets model provider mappings for a specific model ID /// - /// The ID of the model provider mapping to delete + /// The model ID /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// A list of model provider mappings for the specified model + Task> GetByModelIdAsync(int modelId, CancellationToken cancellationToken = default); } } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/INotificationRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/INotificationRepository.cs index 10ec1603d..1e67103f5 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/INotificationRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/INotificationRepository.cs @@ -1,64 +1,50 @@ using ConduitLLM.Configuration.Entities; -namespace ConduitLLM.Configuration.Interfaces +namespace ConduitLLM.Configuration.Interfaces; + +/// +/// Repository interface for managing notifications. +/// Inherits standard CRUD operations from IRepositoryBase. +/// +public interface INotificationRepository : IRepositoryBase { /// - /// Repository interface for managing notifications + /// Gets unread notifications with pagination. /// - public interface INotificationRepository - { - /// - /// Gets a notification by ID - /// - /// The notification ID - /// Cancellation token - /// The notification entity or null if not found - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - - /// - /// Gets all notifications - /// - /// Cancellation token - /// A list of all notifications - Task> GetAllAsync(CancellationToken cancellationToken = default); - - /// - /// Gets unread notifications - /// - /// Cancellation token - /// A list of unread notifications - Task> GetUnreadAsync(CancellationToken cancellationToken = default); + /// The page number (1-based) + /// The number of items per page + /// Cancellation token + /// A tuple with the list of unread notifications and the total count + Task<(List Items, int TotalCount)> GetUnreadPaginatedAsync( + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); - /// - /// Creates a new notification - /// - /// The notification to create - /// Cancellation token - /// The ID of the created notification - Task CreateAsync(Notification notification, CancellationToken cancellationToken = default); - - /// - /// Updates a notification - /// - /// The notification to update - /// Cancellation token - /// True if the update was successful, false otherwise - Task UpdateAsync(Notification notification, CancellationToken cancellationToken = default); + /// + /// Gets unread notifications for a specific virtual key. + /// + /// The virtual key ID + /// Cancellation token + /// A list of unread notifications for the specified virtual key + Task> GetUnreadByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default); - /// - /// Marks a notification as read - /// - /// The ID of the notification to mark as read - /// Cancellation token - /// True if successful, false otherwise - Task MarkAsReadAsync(int id, CancellationToken cancellationToken = default); + /// + /// Gets unread notifications for a specific virtual key and notification type. + /// + /// The virtual key ID + /// The notification type + /// Cancellation token + /// A list of unread notifications matching the criteria + Task> GetUnreadByVirtualKeyAndTypeAsync( + int virtualKeyId, + NotificationType notificationType, + CancellationToken cancellationToken = default); - /// - /// Deletes a notification - /// - /// The ID of the notification to delete - /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteAsync(int id, CancellationToken cancellationToken = default); - } + /// + /// Marks a notification as read. + /// + /// The ID of the notification to mark as read + /// Cancellation token + /// True if successful, false otherwise + Task MarkAsReadAsync(int id, CancellationToken cancellationToken = default); } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IProviderKeyCredentialRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IProviderKeyCredentialRepository.cs index 8291c1152..b6ba6676f 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IProviderKeyCredentialRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IProviderKeyCredentialRepository.cs @@ -3,63 +3,48 @@ namespace ConduitLLM.Configuration.Interfaces { /// - /// Repository interface for ProviderKeyCredential operations + /// Repository interface for ProviderKeyCredential operations. + /// Extends IRepositoryBase for standard CRUD operations and adds domain-specific methods. /// - public interface IProviderKeyCredentialRepository + public interface IProviderKeyCredentialRepository : IRepositoryBase { /// - /// Get all key credentials across all providers + /// Get key credentials for a provider with pagination /// - Task> GetAllAsync(); - - /// - /// Get all key credentials for a provider - /// - Task> GetByProviderIdAsync(int ProviderId); - - /// - /// Get a specific key credential by ID - /// - Task GetByIdAsync(int id); + /// The provider ID + /// The page number (1-based) + /// The number of items per page + /// Cancellation token + /// A tuple with the list of credentials and the total count + Task<(List Items, int TotalCount)> GetByProviderIdPaginatedAsync( + int providerId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); /// /// Get the primary key credential for a provider /// - Task GetPrimaryKeyAsync(int ProviderId); + Task GetPrimaryKeyAsync(int providerId); /// /// Get all enabled key credentials for a provider /// - Task> GetEnabledKeysByProviderIdAsync(int ProviderId); - - /// - /// Create a new key credential - /// - Task CreateAsync(ProviderKeyCredential keyCredential); - - /// - /// Update an existing key credential - /// - Task UpdateAsync(ProviderKeyCredential keyCredential); - - /// - /// Delete a key credential - /// - Task DeleteAsync(int id); + Task> GetEnabledKeysByProviderIdAsync(int providerId); /// /// Set a key as primary (and unset others) /// - Task SetPrimaryKeyAsync(int ProviderId, int keyId); + Task SetPrimaryKeyAsync(int providerId, int keyId); /// /// Check if a provider has any key credentials /// - Task HasKeyCredentialsAsync(int ProviderId); + Task HasKeyCredentialsAsync(int providerId); /// /// Count key credentials for a provider /// - Task CountByProviderIdAsync(int ProviderId); + Task CountByProviderIdAsync(int providerId); } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IProviderRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IProviderRepository.cs index 214ca5f29..4593ab957 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IProviderRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IProviderRepository.cs @@ -1,50 +1,29 @@ using ConduitLLM.Configuration.Entities; -namespace ConduitLLM.Configuration.Interfaces +namespace ConduitLLM.Configuration.Interfaces; + +/// +/// Repository interface for managing providers. +/// Inherits standard CRUD operations from IRepositoryBase. +/// +public interface IProviderRepository : IRepositoryBase { /// - /// Repository interface for managing providers + /// Gets a dictionary mapping provider IDs to their names. /// - public interface IProviderRepository - { - /// - /// Gets a provider by ID - /// - /// The provider ID - /// Cancellation token - /// The provider entity or null if not found - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - - - /// - /// Gets all providers - /// - /// Cancellation token - /// A list of all providers - Task> GetAllAsync(CancellationToken cancellationToken = default); - - /// - /// Creates a new provider - /// - /// The provider to create - /// Cancellation token - /// The ID of the created provider - Task CreateAsync(Provider provider, CancellationToken cancellationToken = default); + /// Cancellation token + /// A dictionary of provider ID to name mappings + /// + /// This method is optimized for lookups when only the name is needed, + /// avoiding the need to load full entities. + /// + Task> GetProviderNameMapAsync(CancellationToken cancellationToken = default); - /// - /// Updates a provider - /// - /// The provider to update - /// Cancellation token - /// True if the update was successful, false otherwise - Task UpdateAsync(Provider provider, CancellationToken cancellationToken = default); - - /// - /// Deletes a provider - /// - /// The ID of the provider to delete - /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteAsync(int id, CancellationToken cancellationToken = default); - } + /// + /// Counts providers with optional filtering by enabled status. + /// + /// If true, only counts enabled providers. If false, only counts disabled. If null, counts all. + /// Cancellation token + /// The count of providers matching the criteria + Task CountAsync(bool? enabledOnly, CancellationToken cancellationToken = default); } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IRepositoryBase.cs b/Shared/ConduitLLM.Configuration/Interfaces/IRepositoryBase.cs new file mode 100644 index 000000000..021220b03 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Interfaces/IRepositoryBase.cs @@ -0,0 +1,91 @@ +namespace ConduitLLM.Configuration.Interfaces; + +/// +/// Base repository interface defining standard CRUD operations for entities. +/// +/// The entity type +/// The primary key type +public interface IRepositoryBase + where TEntity : class + where TKey : IEquatable +{ + /// + /// Gets an entity by its primary key. + /// + /// The entity ID + /// Cancellation token + /// The entity if found, null otherwise + Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default); + + /// + /// Creates a new entity. + /// + /// The entity to create + /// Cancellation token + /// The ID of the created entity + Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// + /// Updates an existing entity. + /// + /// The entity to update + /// Cancellation token + /// True if the update was successful, false otherwise + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// + /// Deletes an entity by its primary key. + /// For entities implementing ISoftDeletable, this performs a soft delete. + /// + /// The entity ID + /// Cancellation token + /// True if the deletion was successful, false otherwise + Task DeleteAsync(TKey id, CancellationToken cancellationToken = default); + + /// + /// Gets a paginated list of entities. + /// + /// Page number (1-based) + /// Number of items per page + /// Cancellation token + /// A tuple containing the items and total count + Task<(List Items, int TotalCount)> GetPaginatedAsync( + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// Checks if an entity with the given ID exists. + /// + /// The entity ID + /// Cancellation token + /// True if the entity exists, false otherwise + Task ExistsAsync(TKey id, CancellationToken cancellationToken = default); + + /// + /// Gets the total count of entities. + /// + /// Cancellation token + /// The total count of entities + Task CountAsync(CancellationToken cancellationToken = default); + + /// + /// Gets all entities WITHOUT pagination. Use ONLY for legitimate batch operations + /// like cache warming, exports, or migrations. + /// + /// + /// This method logs a warning when called to help identify potential performance issues. + /// For high-risk tables (RequestLog, VirtualKey, etc.), use GetPaginatedAsync() instead. + /// + /// Cancellation token + /// List of all entities + Task> GetAllUnboundedAsync(CancellationToken cancellationToken = default); + + /// + /// Gets all entities. Delegates to GetAllUnboundedAsync(). + /// + /// Cancellation token + /// List of all entities + [Obsolete("Use GetAllUnboundedAsync() for cache warming/exports, or GetPaginatedAsync() for bounded queries.")] + Task> GetAllAsync(CancellationToken cancellationToken = default); +} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogRepository.cs index 32366900c..a940b7ce3 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogRepository.cs @@ -4,35 +4,40 @@ namespace ConduitLLM.Configuration.Interfaces { /// - /// Repository interface for managing request logs + /// Repository interface for managing request logs. + /// Extends IRepositoryBase for standard CRUD operations. /// - public interface IRequestLogRepository + public interface IRequestLogRepository : IRepositoryBase { /// - /// Gets a request log by ID - /// - /// The request log ID - /// Cancellation token - /// The request log entity or null if not found - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - - /// - /// Gets all request logs + /// Gets request logs for a specific virtual key /// + /// The virtual key ID /// Cancellation token - /// A list of all request logs - Task> GetAllAsync(CancellationToken cancellationToken = default); + /// A list of request logs for the specified virtual key + /// This method is obsolete. Use GetByVirtualKeyIdPaginatedAsync instead for better performance. + [Obsolete("Use GetByVirtualKeyIdPaginatedAsync instead. This method loads all records into memory and will be removed in a future version.")] + Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default); /// - /// Gets request logs for a specific virtual key + /// Gets paginated request logs for a specific virtual key /// /// The virtual key ID + /// The page number (1-based) + /// The page size /// Cancellation token - /// A list of request logs for the specified virtual key - Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default); + /// A paginated list of request logs for the specified virtual key + Task<(List Logs, int TotalCount)> GetByVirtualKeyIdPaginatedAsync( + int virtualKeyId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); /// - /// Gets request logs for a specific date range + /// Gets request logs for a specific date range. + /// WARNING: Loads all matching rows into memory. Prefer aggregate methods + /// (GetCostsByDateAsync, GetAggregatedByModelAsync, GetSummaryAsync, etc.) + /// for analytics queries, or GetByDateRangePaginatedAsync for browsing. /// /// The start date /// The end date @@ -40,6 +45,72 @@ public interface IRequestLogRepository /// A list of request logs within the specified date range Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + /// + /// Gets request logs for a date range with optional model and virtual key filters + /// applied at the database level. Used by the analytics export endpoint to avoid + /// loading every row in the range when filters are present. + /// + /// The start date. + /// The end date. + /// Optional case-insensitive substring match against ModelName. + /// Optional virtual key ID to filter on. + /// Cancellation token. + Task> GetByDateRangeFilteredAsync( + DateTime startDate, + DateTime endDate, + string? modelFilter = null, + int? virtualKeyId = null, + CancellationToken cancellationToken = default); + + #region Database-Level Aggregation Methods + + /// + /// Gets costs aggregated by date within a date range, computed at the database level. + /// Returns one row per day instead of loading all individual request logs. + /// + Task> GetCostsByDateAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + /// + /// Gets request log data aggregated by model within a date range, computed at the database level. + /// + Task> GetAggregatedByModelAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + /// + /// Gets request log data aggregated by model for a specific virtual key, computed at the database level. + /// + Task> GetAggregatedByModelForVirtualKeyAsync( + int virtualKeyId, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + /// + /// Gets request log data aggregated by virtual key within a date range, computed at the database level. + /// + Task> GetAggregatedByVirtualKeyAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + /// + /// Gets summary statistics (totals) for a date range in a single database query. + /// Returns one row with aggregate counts, sums, and averages. + /// + Task GetSummaryAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + /// + /// Gets summary statistics for a specific virtual key and date range in a single database query. + /// + Task GetSummaryForVirtualKeyAsync( + int virtualKeyId, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + /// + /// Gets daily statistics (per-day breakdown) within a date range, computed at the database level. + /// Can be further aggregated to weekly/monthly in C# with minimal overhead (~365 rows/year). + /// + Task> GetDailyStatisticsAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + #endregion + /// /// Gets paginated request logs for a specific date range /// @@ -50,52 +121,32 @@ public interface IRequestLogRepository /// Cancellation token /// A paginated list of request logs within the specified date range Task<(List Logs, int TotalCount)> GetByDateRangePaginatedAsync( - DateTime startDate, - DateTime endDate, - int pageNumber, - int pageSize, + DateTime startDate, + DateTime endDate, + int pageNumber, + int pageSize, CancellationToken cancellationToken = default); /// - /// Gets request logs for a specific model + /// Gets paginated request logs for a specific model /// /// The model name - /// Cancellation token - /// A list of request logs for the specified model - Task> GetByModelAsync(string modelName, CancellationToken cancellationToken = default); - - /// - /// Gets paginated request logs - /// /// The page number (1-based) /// The page size /// Cancellation token - /// A paginated list of request logs - Task<(List Logs, int TotalCount)> GetPaginatedAsync(int pageNumber, int pageSize, CancellationToken cancellationToken = default); - - /// - /// Creates a new request log - /// - /// The request log to create - /// Cancellation token - /// The ID of the created request log - Task CreateAsync(RequestLog requestLog, CancellationToken cancellationToken = default); - - /// - /// Updates a request log - /// - /// The request log to update - /// Cancellation token - /// True if the update was successful, false otherwise - Task UpdateAsync(RequestLog requestLog, CancellationToken cancellationToken = default); + /// A paginated list of request logs for the specified model + Task<(List Logs, int TotalCount)> GetByModelPaginatedAsync( + string modelName, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); /// - /// Deletes a request log + /// Gets distinct model names from request logs /// - /// The ID of the request log to delete /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// A list of distinct model names used in request logs + Task> GetDistinctModelsAsync(CancellationToken cancellationToken = default); /// /// Gets usage statistics diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogService.cs b/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogService.cs index e153ec542..728b6cedb 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogService.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IRequestLogService.cs @@ -92,5 +92,16 @@ public interface IRequestLogService /// /// List of distinct model names Task> GetDistinctModelsAsync(); + + /// + /// Forces a flush of all pending request logs to the database. + /// Use when immediate persistence is required. + /// + Task FlushEventsAsync(); + + /// + /// Removes request logs older than the retention period (90 days). + /// + Task CleanupOldRequestLogsAsync(); } } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyGroupRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyGroupRepository.cs index 2fd446e92..8dd3f90ab 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyGroupRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyGroupRepository.cs @@ -4,17 +4,11 @@ namespace ConduitLLM.Configuration.Interfaces; /// -/// Repository interface for managing virtual key groups +/// Repository interface for managing virtual key groups. +/// Extends IRepositoryBase for standard CRUD operations and adds domain-specific methods. /// -public interface IVirtualKeyGroupRepository +public interface IVirtualKeyGroupRepository : IRepositoryBase { - /// - /// Gets a virtual key group by ID - /// - /// The group ID - /// The virtual key group or null if not found - Task GetByIdAsync(int id); - /// /// Gets a virtual key group by ID with its associated keys /// @@ -29,33 +23,6 @@ public interface IVirtualKeyGroupRepository /// The virtual key group or null if not found Task GetByKeyIdAsync(int virtualKeyId); - /// - /// Gets all virtual key groups - /// - /// List of all virtual key groups - Task> GetAllAsync(); - - /// - /// Creates a new virtual key group - /// - /// The group to create - /// The ID of the created group - Task CreateAsync(VirtualKeyGroup group); - - /// - /// Updates an existing virtual key group - /// - /// The group to update - /// True if updated successfully - Task UpdateAsync(VirtualKeyGroup group); - - /// - /// Deletes a virtual key group - /// - /// The group ID to delete - /// True if deleted successfully - Task DeleteAsync(int id); - /// /// Adjusts the balance of a virtual key group /// @@ -87,9 +54,16 @@ public interface IVirtualKeyGroupRepository Task AdjustBalanceAsync(int groupId, decimal amount, string? description, string? initiatedBy, ReferenceType referenceType, string? referenceId = null); /// - /// Gets groups with low balance (below threshold) + /// Gets groups with low balance (below threshold) with pagination /// /// The balance threshold - /// List of groups with balance below threshold - Task> GetLowBalanceGroupsAsync(decimal threshold); -} \ No newline at end of file + /// The page number (1-based) + /// The number of items per page + /// Cancellation token + /// A tuple with the list of groups and the total count + Task<(List Items, int TotalCount)> GetLowBalanceGroupsPaginatedAsync( + decimal threshold, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); +} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyGroupTransactionRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyGroupTransactionRepository.cs new file mode 100644 index 000000000..687106584 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyGroupTransactionRepository.cs @@ -0,0 +1,57 @@ +using ConduitLLM.Configuration.Entities; + +namespace ConduitLLM.Configuration.Interfaces; + +/// +/// Repository interface for managing virtual key group transactions. +/// Extends IRepositoryBase for standard CRUD operations and adds domain-specific methods. +/// +public interface IVirtualKeyGroupTransactionRepository : IRepositoryBase +{ + /// + /// Gets all transactions for a specific virtual key group + /// + /// The virtual key group ID + /// Cancellation token + /// A list of transactions ordered by CreatedAt descending + Task> GetByGroupIdAsync(int groupId, CancellationToken cancellationToken = default); + + /// + /// Gets transactions within a date range + /// + /// The start date + /// The end date + /// Cancellation token + /// A list of transactions with VirtualKeyGroup navigation property included + Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); + + /// + /// Gets transactions for a virtual key group within a date range + /// + /// The virtual key group ID + /// The start date + /// The end date + /// Cancellation token + /// A list of transactions ordered by CreatedAt descending + Task> GetByGroupIdAndDateRangeAsync( + int groupId, + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken = default); + + /// + /// Gets the total credits added for a virtual key group + /// + /// The virtual key group ID + /// Cancellation token + /// The total amount of credits added + Task GetTotalCreditsAsync(int groupId, CancellationToken cancellationToken = default); + + /// + /// Gets the total debits for a virtual key group + /// + /// The virtual key group ID + /// Cancellation token + /// The total amount of debits + Task GetTotalDebitsAsync(int groupId, CancellationToken cancellationToken = default); +} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyRepository.cs index 8b4e4e243..4e0ae1ef2 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyRepository.cs @@ -15,33 +15,17 @@ namespace ConduitLLM.Configuration.Interfaces /// Key features of the virtual key repository: /// /// - /// CRUD operations for virtual key entities + /// CRUD operations for virtual key entities (inherited from IRepositoryBase) /// Lookup by ID or key hash for authentication /// Support for tracking creation and update timestamps /// /// - /// This interface follows the repository pattern, abstracting the data access layer - /// and providing a clean, domain-focused API for virtual key management. + /// This interface extends for standard CRUD operations + /// and adds domain-specific methods for virtual key management. /// /// - public interface IVirtualKeyRepository + public interface IVirtualKeyRepository : IRepositoryBase { - /// - /// Retrieves a virtual key entity by its unique identifier. - /// - /// The unique identifier of the virtual key. - /// A token to cancel the asynchronous operation. - /// - /// A task that represents the asynchronous operation. The task result contains the - /// virtual key entity if found, or null if no virtual key with the specified ID exists. - /// - /// - /// This method performs a non-tracking query, meaning the entity returned is not - /// tracked by the Entity Framework change tracker. This is suitable for read-only - /// scenarios and improves performance. - /// - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - /// /// Retrieves a virtual key entity by its hashed key value. /// @@ -67,147 +51,84 @@ public interface IVirtualKeyRepository Task GetByKeyHashAsync(string keyHash, CancellationToken cancellationToken = default); /// - /// Retrieves all virtual key entities in the system. + /// Retrieves virtual key entities belonging to a specific group with pagination. /// + /// The ID of the virtual key group. + /// The page number (1-based). + /// The number of items per page. /// A token to cancel the asynchronous operation. /// /// A task that represents the asynchronous operation. The task result contains - /// a list of all virtual key entities, ordered by key name. + /// a tuple with the list of virtual keys and the total count. /// - /// - /// - /// This method returns all virtual keys sorted alphabetically by their key names. - /// It is primarily used by administrative interfaces to display and manage all - /// virtual keys in the system. - /// - /// - /// The method performs a non-tracking query, meaning the entities returned are not - /// tracked by the Entity Framework change tracker. This is suitable for read-only - /// scenarios and improves performance, especially when dealing with potentially - /// large numbers of entities. - /// - /// - Task> GetAllAsync(CancellationToken cancellationToken = default); + Task<(List Items, int TotalCount)> GetByVirtualKeyGroupIdPaginatedAsync( + int virtualKeyGroupId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); /// - /// Retrieves all virtual key entities belonging to a specific group. + /// Retrieves key names for a set of virtual key IDs. /// - /// The ID of the virtual key group. + /// The virtual key IDs to look up. /// A token to cancel the asynchronous operation. /// /// A task that represents the asynchronous operation. The task result contains - /// a list of virtual key entities belonging to the specified group. + /// a dictionary mapping virtual key IDs to their names. /// /// - /// This method is used for filtering virtual keys by their group membership, - /// which is useful for organizational and reporting purposes. + /// This method is optimized for bulk lookups when only the name is needed, + /// avoiding the need to load full entities. /// - Task> GetByVirtualKeyGroupIdAsync(int virtualKeyGroupId, CancellationToken cancellationToken = default); + Task> GetKeyNamesByIdsAsync( + IEnumerable ids, + CancellationToken cancellationToken = default); /// - /// Creates a new virtual key entity in the database. + /// Counts active (enabled and non-expired) virtual keys. /// - /// The virtual key entity to create. /// A token to cancel the asynchronous operation. /// /// A task that represents the asynchronous operation. The task result contains - /// the assigned ID of the newly created virtual key entity. + /// the count of active virtual keys. /// - /// - /// - /// When creating a new virtual key, the implementation should ensure that: - /// - /// - /// The key name is unique within the system - /// The key hash represents a securely hashed value of the actual key - /// Creation and update timestamps are properly set - /// - /// - /// The database will assign a unique identifier to the new entity, which is returned by this method. - /// This ID can be used for subsequent operations on the virtual key. - /// - /// - /// Thrown when the virtualKey parameter is null. - /// May be thrown when a database constraint is violated. - Task CreateAsync(VirtualKey virtualKey, CancellationToken cancellationToken = default); + Task CountActiveAsync(CancellationToken cancellationToken = default); /// - /// Updates an existing virtual key entity in the database. + /// Deletes a virtual key entity from the database by key hash. /// - /// The virtual key entity with updated values. + /// The hashed key value of the virtual key to delete. /// A token to cancel the asynchronous operation. /// /// A task that represents the asynchronous operation. The task result is a boolean value - /// indicating whether the update was successful (true) or if the entity wasn't found or - /// wasn't modified (false). + /// indicating whether the deletion was successful (true) or if the entity wasn't found (false). /// /// - /// - /// This method updates all properties of the virtual key entity except for any identity - /// or concurrency tokens. The implementation should automatically update the UpdatedAt - /// timestamp to reflect when the change occurred. - /// - /// - /// The method should handle concurrency conflicts gracefully, typically by applying a - /// last-writer-wins strategy or by providing detailed concurrency exception information. - /// - /// - /// Common properties that might be updated include: - /// - /// - /// Key name - the display name for the virtual key - /// Expiration date - when the key becomes invalid - /// Token limits - maximum token usage allowed - /// Rate limits - requests per minute/hour/day - /// Status - whether the key is enabled or disabled - /// + /// This method is used for cache invalidation scenarios where we have the key hash + /// but not the database ID. /// - /// Thrown when the virtualKey parameter is null. - /// May be thrown when a concurrency conflict occurs. - Task UpdateAsync(VirtualKey virtualKey, CancellationToken cancellationToken = default); + Task DeleteAsync(string keyHash, CancellationToken cancellationToken = default); /// - /// Deletes a virtual key entity from the database. + /// Retrieves a limited number of enabled virtual key entities, ordered by key name. /// - /// The unique identifier of the virtual key to delete. + /// The maximum number of virtual keys to retrieve. /// A token to cancel the asynchronous operation. /// - /// A task that represents the asynchronous operation. The task result is a boolean value - /// indicating whether the deletion was successful (true) or if the entity wasn't found (false). + /// A task that represents the asynchronous operation. The task result contains + /// a list of up to enabled virtual key entities. /// /// /// - /// This method completely removes the virtual key entity from the database. This is a - /// permanent operation that cannot be undone through the application. + /// This method is optimized for scenarios where only a small subset of enabled keys is needed, + /// such as dashboard displays or metrics collection. Unlike , it applies + /// filtering and limiting at the database level to avoid loading unnecessary data. /// /// - /// The implementation should ensure that any related entities, such as usage history - /// or request logs that reference this virtual key, are handled appropriately according - /// to the database's referential integrity rules. This might include: + /// The method performs a non-tracking query for optimal read performance. /// - /// - /// Cascading deletes to remove related records - /// Setting null values in foreign key fields of related entities - /// Preventing deletion if related records exist and require the virtual key - /// - /// - /// May be thrown when a database constraint prevents deletion. - Task DeleteAsync(int id, CancellationToken cancellationToken = default); - - /// - /// Deletes a virtual key entity from the database by key hash. - /// - /// The hashed key value of the virtual key to delete. - /// A token to cancel the asynchronous operation. - /// - /// A task that represents the asynchronous operation. The task result is a boolean value - /// indicating whether the deletion was successful (true) or if the entity wasn't found (false). - /// - /// - /// This method is used for cache invalidation scenarios where we have the key hash - /// but not the database ID. /// - Task DeleteAsync(string keyHash, CancellationToken cancellationToken = default); + Task> GetTopEnabledAsync(int count, CancellationToken cancellationToken = default); } } diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyService.cs b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyService.cs deleted file mode 100644 index 57c235978..000000000 --- a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeyService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using ConduitLLM.Configuration.Entities; - -namespace ConduitLLM.Configuration.Interfaces -{ - /// - /// Service for managing virtual keys - /// - public interface IVirtualKeyService - { - /// - /// Gets all virtual keys - /// - Task> GetAllVirtualKeysAsync(); - - /// - /// Gets a virtual key by its ID - /// - /// The ID of the virtual key - Task GetVirtualKeyByIdAsync(int id); - - /// - /// Gets a virtual key by its key value - /// - /// The key value - Task GetVirtualKeyByKeyValueAsync(string keyValue); - - /// - /// Creates a new virtual key - /// - /// The virtual key to create - Task CreateVirtualKeyAsync(VirtualKey virtualKey); - - /// - /// Updates an existing virtual key - /// - /// The updated virtual key - Task UpdateVirtualKeyAsync(VirtualKey virtualKey); - - /// - /// Deletes a virtual key by its ID - /// - /// The ID of the virtual key to delete - Task DeleteVirtualKeyAsync(int id); - - /// - /// Validates if a virtual key is valid for authentication only - /// - /// The key value to validate - /// Optional model being requested - /// The virtual key entity if valid for authentication, null otherwise - Task ValidateVirtualKeyForAuthenticationAsync(string keyValue, string? requestedModel = null); - - /// - /// Validates if a virtual key is valid for use - /// - /// The key value to validate - /// True if the key is valid, otherwise false - Task ValidateVirtualKeyAsync(string keyValue); - - /// - /// Resets the spend for a virtual key - /// - /// The ID of the virtual key - Task ResetSpendAsync(int id); - - /// - /// Updates the spend for a virtual key - /// - /// The ID of the virtual key - /// Additional amount to add to the current spend - Task UpdateSpendAsync(int id, decimal additionalSpend); - } -} diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeySpendHistoryRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeySpendHistoryRepository.cs index fb5f4ec05..8e1b6ede1 100644 --- a/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeySpendHistoryRepository.cs +++ b/Shared/ConduitLLM.Configuration/Interfaces/IVirtualKeySpendHistoryRepository.cs @@ -3,24 +3,17 @@ namespace ConduitLLM.Configuration.Interfaces { /// - /// Repository interface for managing virtual key spend history + /// Repository interface for managing virtual key spend history. + /// Extends IRepositoryBase for standard CRUD operations and adds domain-specific methods. /// - public interface IVirtualKeySpendHistoryRepository + public interface IVirtualKeySpendHistoryRepository : IRepositoryBase { - /// - /// Gets a spend history record by ID - /// - /// The spend history record ID - /// Cancellation token - /// The spend history entity or null if not found - Task GetByIdAsync(int id, CancellationToken cancellationToken = default); - /// /// Gets all spend history records for a specific virtual key /// /// The virtual key ID /// Cancellation token - /// A list of spend history records + /// A list of spend history records ordered by timestamp descending Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default); /// @@ -29,7 +22,7 @@ public interface IVirtualKeySpendHistoryRepository /// The start date /// The end date /// Cancellation token - /// A list of spend history records + /// A list of spend history records with VirtualKey navigation property included Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); /// @@ -39,7 +32,7 @@ public interface IVirtualKeySpendHistoryRepository /// The start date /// The end date /// Cancellation token - /// A list of spend history records + /// A list of spend history records ordered by timestamp descending Task> GetByVirtualKeyAndDateRangeAsync( int virtualKeyId, DateTime startDate, @@ -47,31 +40,7 @@ Task> GetByVirtualKeyAndDateRangeAsync( CancellationToken cancellationToken = default); /// - /// Creates a new spend history record - /// - /// The spend history to create - /// Cancellation token - /// The ID of the created spend history record - Task CreateAsync(VirtualKeySpendHistory spendHistory, CancellationToken cancellationToken = default); - - /// - /// Updates a spend history record - /// - /// The spend history to update - /// Cancellation token - /// True if the update was successful, false otherwise - Task UpdateAsync(VirtualKeySpendHistory spendHistory, CancellationToken cancellationToken = default); - - /// - /// Deletes a spend history record - /// - /// The ID of the spend history record to delete - /// Cancellation token - /// True if the deletion was successful, false otherwise - Task DeleteAsync(int id, CancellationToken cancellationToken = default); - - /// - /// Gets a summary of spending for a virtual key + /// Gets the total amount spent for a virtual key /// /// The virtual key ID /// Cancellation token diff --git a/Shared/ConduitLLM.Configuration/Interfaces/IpFilterRepository.cs b/Shared/ConduitLLM.Configuration/Interfaces/IpFilterRepository.cs deleted file mode 100644 index 8d85df599..000000000 --- a/Shared/ConduitLLM.Configuration/Interfaces/IpFilterRepository.cs +++ /dev/null @@ -1,186 +0,0 @@ -using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Utilities; - -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Configuration.Interfaces; - -/// -/// Repository implementation for IP filter management -/// -public class IpFilterRepository : IIpFilterRepository -{ - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class - /// - /// Database context factory - /// Logger - public IpFilterRepository( - IDbContextFactory dbContextFactory, - ILogger logger) - { - _dbContextFactory = dbContextFactory; - _logger = logger; - } - - /// - public async Task> GetAllAsync() - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.IpFilters - .OrderBy(f => f.FilterType) - .ThenBy(f => f.IpAddressOrCidr) - .ToListAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error getting all IP filters"); - return Enumerable.Empty(); - } - } - - /// - public async Task> GetEnabledAsync() - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.IpFilters - .Where(f => f.IsEnabled) - .OrderBy(f => f.FilterType) - .ThenBy(f => f.IpAddressOrCidr) - .ToListAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error getting enabled IP filters"); - return Enumerable.Empty(); - } - } - - /// - public async Task GetByIdAsync(int id) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - return await dbContext.IpFilters.FindAsync(id); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error getting IP filter with ID {Id}", - id); - return null; - } - } - - /// - public async Task AddAsync(IpFilterEntity filter) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - - // Set default dates - filter.CreatedAt = DateTime.UtcNow; - filter.UpdatedAt = DateTime.UtcNow; - - dbContext.IpFilters.Add(filter); - await dbContext.SaveChangesAsync(); - - _logger.LogInformation("Added new IP filter: {FilterType} {IpAddressOrCidr}", - LoggingSanitizer.S(filter.FilterType), - LoggingSanitizer.S(filter.IpAddressOrCidr)); - - return filter; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding IP filter for {IpAddressOrCidr}", LoggingSanitizer.S(filter.IpAddressOrCidr)); - throw; - } - } - - /// - public async Task UpdateAsync(IpFilterEntity filter) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - - var existingFilter = await dbContext.IpFilters.FindAsync(filter.Id); - if (existingFilter == null) - { - _logger.LogWarning("IP filter with ID {Id} not found for update", - filter.Id); - return false; - } - - // Update properties - existingFilter.FilterType = filter.FilterType; - existingFilter.IpAddressOrCidr = filter.IpAddressOrCidr; - existingFilter.Description = filter.Description; - existingFilter.IsEnabled = filter.IsEnabled; - existingFilter.UpdatedAt = DateTime.UtcNow; - - await dbContext.SaveChangesAsync(); - - _logger.LogInformation("Updated IP filter ID {Id}: {FilterType} {IpAddressOrCidr}", - filter.Id, - LoggingSanitizer.S(filter.FilterType), - LoggingSanitizer.S(filter.IpAddressOrCidr)); - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error updating IP filter with ID {Id}", - filter.Id); - throw; - } - } - - /// - public async Task DeleteAsync(int id) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(); - - var filter = await dbContext.IpFilters.FindAsync(id); - if (filter == null) - { - _logger.LogWarning("IP filter with ID {Id} not found for deletion", - id); - return false; - } - - dbContext.IpFilters.Remove(filter); - await dbContext.SaveChangesAsync(); - - _logger.LogInformation("Deleted IP filter ID {Id}: {FilterType} {IpAddressOrCidr}", - id, - LoggingSanitizer.S(filter.FilterType), - LoggingSanitizer.S(filter.IpAddressOrCidr)); - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error deleting IP filter with ID {Id}", - id); - throw; - } - } -} diff --git a/Shared/ConduitLLM.Configuration/Migrations/20260318191532_AddCachedTokenFieldsToRequestLogs.Designer.cs b/Shared/ConduitLLM.Configuration/Migrations/20260318191532_AddCachedTokenFieldsToRequestLogs.Designer.cs new file mode 100644 index 000000000..95bb44b54 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Migrations/20260318191532_AddCachedTokenFieldsToRequestLogs.Designer.cs @@ -0,0 +1,2338 @@ +๏ปฟ// +using System; +using ConduitLLM.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ConduitLLM.Configuration.Migrations +{ + [DbContext(typeof(ConduitDbContext))] + [Migration("20260318191532_AddCachedTokenFieldsToRequestLogs")] + partial class AddCachedTokenFieldsToRequestLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.AsyncTask", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsRetryable") + .HasColumnType("boolean"); + + b.Property("LeaseExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LeasedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MaxRetries") + .HasColumnType("integer"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Progress") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Result") + .HasColumnType("text"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsArchived"); + + b.HasIndex("State"); + + b.HasIndex("Type"); + + b.HasIndex("VirtualKeyId"); + + b.HasIndex("IsArchived", "ArchivedAt") + .HasDatabaseName("IX_AsyncTasks_Cleanup"); + + b.HasIndex("VirtualKeyId", "CreatedAt"); + + b.HasIndex("IsArchived", "CompletedAt", "State") + .HasDatabaseName("IX_AsyncTasks_Archival"); + + b.ToTable("AsyncTasks"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.BatchOperationHistory", b => + { + b.Property("OperationId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CanResume") + .HasColumnType("boolean"); + + b.Property("CancellationReason") + .HasColumnType("text"); + + b.Property("CheckpointData") + .HasColumnType("text"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FailedCount") + .HasColumnType("integer"); + + b.Property("ItemsPerSecond") + .HasColumnType("double precision"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("OperationType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResultSummary") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SuccessCount") + .HasColumnType("integer"); + + b.Property("TotalItems") + .HasColumnType("integer"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("OperationId"); + + b.HasIndex("OperationType"); + + b.HasIndex("StartedAt"); + + b.HasIndex("Status"); + + b.HasIndex("VirtualKeyId"); + + b.HasIndex("VirtualKeyId", "StartedAt"); + + b.HasIndex("OperationType", "Status", "StartedAt"); + + b.ToTable("BatchOperationHistory"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.BillingAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedCost") + .HasColumnType("decimal(10, 6)"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HttpStatusCode") + .HasColumnType("integer"); + + b.Property("IsEstimated") + .HasColumnType("boolean"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProviderType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ToolUsageCost") + .HasColumnType("decimal(10, 6)"); + + b.Property("ToolUsageJson") + .HasColumnType("jsonb"); + + b.Property("UsageJson") + .HasColumnType("jsonb"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EventType") + .HasDatabaseName("IX_BillingAuditEvents_EventType"); + + b.HasIndex("RequestId") + .HasDatabaseName("IX_BillingAuditEvents_RequestId"); + + b.HasIndex("Timestamp") + .HasDatabaseName("IX_BillingAuditEvents_Timestamp"); + + b.HasIndex("VirtualKeyId") + .HasDatabaseName("IX_BillingAuditEvents_VirtualKeyId"); + + b.HasIndex("EventType", "Timestamp") + .HasDatabaseName("IX_BillingAuditEvents_EventType_Timestamp"); + + b.HasIndex("VirtualKeyId", "Timestamp") + .HasDatabaseName("IX_BillingAuditEvents_VirtualKeyId_Timestamp"); + + b.ToTable("BillingAuditEvents", (string)null); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.CacheConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompressionThresholdBytes") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DefaultTtlSeconds") + .HasColumnType("integer"); + + b.Property("EnableCompression") + .HasColumnType("boolean"); + + b.Property("EnableDetailedStats") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EvictionPolicy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExtendedConfig") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaxEntries") + .HasColumnType("bigint"); + + b.Property("MaxMemoryBytes") + .HasColumnType("bigint"); + + b.Property("MaxTtlSeconds") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UseDistributedCache") + .HasColumnType("boolean"); + + b.Property("UseMemoryCache") + .HasColumnType("boolean"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Region") + .IsUnique() + .HasFilter("\"IsActive\" = true"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("Region", "IsActive"); + + b.ToTable("CacheConfigurations"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.CacheConfigurationAudit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangeSource") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("NewConfigJson") + .HasColumnType("text"); + + b.Property("OldConfigJson") + .HasColumnType("text"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAt"); + + b.HasIndex("ChangedBy"); + + b.HasIndex("Region"); + + b.HasIndex("Region", "ChangedAt"); + + b.ToTable("CacheConfigurationAudits"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.GlobalSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("GlobalSettings"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.IpFilterEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FilterType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IpAddressOrCidr") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("FilterType", "IpAddressOrCidr"); + + b.ToTable("IpFilters"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.MediaRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("Provider") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicUrl") + .HasColumnType("text"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StorageUrl") + .HasColumnType("text"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("StorageKey") + .IsUnique(); + + b.HasIndex("VirtualKeyId"); + + b.HasIndex("VirtualKeyId", "CreatedAt"); + + b.ToTable("MediaRecords"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.MediaRetentionPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("MaxFileCount") + .HasColumnType("integer"); + + b.Property("MaxStorageSizeBytes") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NegativeBalanceRetentionDays") + .HasColumnType("integer"); + + b.Property("PositiveBalanceRetentionDays") + .HasColumnType("integer"); + + b.Property("RecentAccessWindowDays") + .HasColumnType("integer"); + + b.Property("RespectRecentAccess") + .HasColumnType("boolean"); + + b.Property("SoftDeleteGracePeriodDays") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ZeroBalanceRetentionDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsDefault") + .IsUnique() + .HasFilter("\"IsDefault\" = true"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaRetentionPolicies"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.Model", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaxInputTokens") + .HasColumnType("integer"); + + b.Property("MaxOutputTokens") + .HasColumnType("integer"); + + b.Property("ModelCardUrl") + .HasColumnType("text"); + + b.Property("ModelParameters") + .HasColumnType("text") + .HasColumnName("Parameters"); + + b.Property("ModelSeriesId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupportsChat") + .HasColumnType("boolean"); + + b.Property("SupportsEmbeddings") + .HasColumnType("boolean"); + + b.Property("SupportsFunctionCalling") + .HasColumnType("boolean"); + + b.Property("SupportsImageGeneration") + .HasColumnType("boolean"); + + b.Property("SupportsStreaming") + .HasColumnType("boolean"); + + b.Property("SupportsVideoGeneration") + .HasColumnType("boolean"); + + b.Property("SupportsVision") + .HasColumnType("boolean"); + + b.Property("TokenizerType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ModelSeriesId") + .HasDatabaseName("IX_Model_ModelSeriesId"); + + b.ToTable("Models"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("WebsiteUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("IX_ModelAuthor_Name_Unique"); + + b.ToTable("ModelAuthors"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelCost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatchProcessingMultiplier") + .HasColumnType("decimal(18, 4)"); + + b.Property("CachedInputCostPerMillionTokens") + .HasColumnType("decimal(18, 10)"); + + b.Property("CachedInputWriteCostPerMillionTokens") + .HasColumnType("decimal(18, 10)"); + + b.Property("CostName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CostPerSearchUnit") + .HasColumnType("decimal(18, 8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EffectiveDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EmbeddingCostPerMillionTokens") + .HasColumnType("decimal(18, 10)"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InputCostPerMillionTokens") + .HasColumnType("decimal(18, 10)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ModelType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OutputCostPerMillionTokens") + .HasColumnType("decimal(18, 10)"); + + b.Property("PricingConfiguration") + .HasColumnType("text"); + + b.Property("PricingModel") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ReasoningCostPerMillionTokens") + .HasColumnType("decimal(18, 10)"); + + b.Property("SupportsBatchProcessing") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CostName"); + + b.ToTable("ModelCosts"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelProviderMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("ModelAlias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ModelProviderTypeAssociationId") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("integer"); + + b.Property("ProviderModelId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ModelProviderTypeAssociationId") + .HasDatabaseName("IX_ModelProviderMapping_ModelProviderTypeAssociationId"); + + b.HasIndex("ModelAlias", "ProviderId") + .IsUnique(); + + b.HasIndex("ProviderId", "IsEnabled") + .HasDatabaseName("IX_ModelProviderMapping_ProviderId_IsEnabled") + .HasFilter("\"IsEnabled\" = true"); + + b.ToTable("ModelProviderMappings"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelProviderTypeAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("MaxInputTokens") + .HasColumnType("integer"); + + b.Property("MaxOutputTokens") + .HasColumnType("integer"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ModelCostId") + .HasColumnType("integer"); + + b.Property("ModelId") + .HasColumnType("integer"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderVariation") + .HasColumnType("text"); + + b.Property("QualityScore") + .HasColumnType("numeric"); + + b.Property("SpeedScore") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasDatabaseName("IX_ModelIdentifier_Identifier"); + + b.HasIndex("IsPrimary") + .HasDatabaseName("IX_ModelIdentifier_IsPrimary") + .HasFilter("\"IsPrimary\" = true"); + + b.HasIndex("ModelCostId"); + + b.HasIndex("ModelId") + .HasDatabaseName("IX_ModelIdentifier_ModelId"); + + b.HasIndex("Provider", "Identifier") + .IsUnique() + .HasDatabaseName("IX_ModelIdentifier_Provider_Identifier_Unique"); + + b.ToTable("ModelIdentifiers", (string)null); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenizerType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId") + .HasDatabaseName("IX_ModelSeries_AuthorId"); + + b.HasIndex("TokenizerType") + .HasDatabaseName("IX_ModelSeries_TokenizerType"); + + b.HasIndex("AuthorId", "Name") + .IsUnique() + .HasDatabaseName("IX_ModelSeries_AuthorId_Name_Unique"); + + b.ToTable("ModelSeries"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VirtualKeyId"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.PricingAuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppliedRate") + .HasColumnType("decimal(10, 8)"); + + b.Property("CalculatedCost") + .HasColumnType("decimal(10, 6)"); + + b.Property("InputParameters") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValue("{}"); + + b.Property("MatchedRule") + .HasColumnType("jsonb"); + + b.Property("ModelCostId") + .HasColumnType("integer"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PricingType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Quantity") + .HasColumnType("decimal(10, 4)"); + + b.Property("RequestId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UsedDefaultRate") + .HasColumnType("boolean"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ModelId") + .HasDatabaseName("IX_PricingAuditEvents_ModelId"); + + b.HasIndex("PricingType") + .HasDatabaseName("IX_PricingAuditEvents_PricingType"); + + b.HasIndex("RequestId") + .HasDatabaseName("IX_PricingAuditEvents_RequestId"); + + b.HasIndex("Timestamp") + .HasDatabaseName("IX_PricingAuditEvents_Timestamp"); + + b.HasIndex("VirtualKeyId") + .HasDatabaseName("IX_PricingAuditEvents_VirtualKeyId"); + + b.HasIndex("ModelId", "Timestamp") + .HasDatabaseName("IX_PricingAuditEvents_ModelId_Timestamp"); + + b.HasIndex("PricingType", "Timestamp") + .HasDatabaseName("IX_PricingAuditEvents_PricingType_Timestamp"); + + b.HasIndex("VirtualKeyId", "Timestamp") + .HasDatabaseName("IX_PricingAuditEvents_VirtualKeyId_Timestamp"); + + b.ToTable("PricingAuditEvents", (string)null); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.Provider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaseUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProviderType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProviderType"); + + b.ToTable("Providers"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ProviderKeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("BaseUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("KeyName") + .HasColumnType("text"); + + b.Property("Organization") + .HasColumnType("text"); + + b.Property("ProviderAccountGroup") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId") + .HasDatabaseName("IX_ProviderKeyCredential_ProviderId"); + + b.HasIndex("ProviderId", "ApiKey") + .IsUnique() + .HasDatabaseName("IX_ProviderKeyCredential_UniqueApiKeyPerProvider") + .HasFilter("\"ApiKey\" IS NOT NULL"); + + b.HasIndex("ProviderId", "IsPrimary") + .IsUnique() + .HasDatabaseName("IX_ProviderKeyCredential_OnePrimaryPerProvider") + .HasFilter("\"IsPrimary\" = true"); + + b.ToTable("ProviderKeyCredentials", t => + { + t.HasCheckConstraint("CK_ProviderKeyCredential_AccountGroupRange", "\"ProviderAccountGroup\" >= 0 AND \"ProviderAccountGroup\" <= 32"); + + t.HasCheckConstraint("CK_ProviderKeyCredential_PrimaryMustBeEnabled", "\"IsPrimary\" = false OR \"IsEnabled\" = true"); + }); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ProviderTool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BillingUnit") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CostDescription") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CostPerUnit") + .HasColumnType("decimal(10, 6)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ToolName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToolParameters") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_ProviderTool_IsActive"); + + b.HasIndex("Provider", "ToolName") + .IsUnique() + .HasDatabaseName("IX_ProviderTool_Provider_ToolName"); + + b.ToTable("ProviderTools"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.RequestLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedInputTokens") + .HasColumnType("integer"); + + b.Property("CachedWriteTokens") + .HasColumnType("integer"); + + b.Property("ClientIp") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Cost") + .HasColumnType("decimal(10, 6)"); + + b.Property("InputTokens") + .HasColumnType("integer"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("ModelName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OutputTokens") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("integer"); + + b.Property("ProviderType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseTimeMs") + .HasColumnType("double precision"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VirtualKeyId"); + + b.ToTable("RequestLogs"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowedModels") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("KeyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("RateLimitRpd") + .HasColumnType("integer"); + + b.Property("RateLimitRpm") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VirtualKeyGroupId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("VirtualKeyGroupId"); + + b.ToTable("VirtualKeys"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKeyGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Balance") + .HasColumnType("decimal(19, 8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalGroupId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LifetimeCreditsAdded") + .HasColumnType("decimal(19, 8)"); + + b.Property("LifetimeSpent") + .HasColumnType("decimal(19, 8)"); + + b.Property("MediaRetentionPolicyId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExternalGroupId"); + + b.HasIndex("MediaRetentionPolicyId"); + + b.ToTable("VirtualKeyGroups"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKeyGroupTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18, 6)"); + + b.Property("BalanceAfter") + .HasColumnType("decimal(18, 6)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("InitiatedBy") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InitiatedByUserId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ReferenceType") + .HasColumnType("integer"); + + b.Property("TransactionType") + .HasColumnType("integer"); + + b.Property("VirtualKeyGroupId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ReferenceType"); + + b.HasIndex("TransactionType"); + + b.HasIndex("VirtualKeyGroupId"); + + b.HasIndex("IsDeleted", "CreatedAt"); + + b.HasIndex("VirtualKeyGroupId", "CreatedAt"); + + b.ToTable("VirtualKeyGroupTransactions"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKeySpendHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(10, 6)"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VirtualKeyId"); + + b.ToTable("VirtualKeySpendHistory"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionCallAudit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatCompletionId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasColumnType("decimal(18,8)"); + + b.Property("ErrorMessage") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FunctionCallJson") + .HasColumnType("jsonb"); + + b.Property("FunctionConfigurationId") + .HasColumnType("integer"); + + b.Property("FunctionExecutionId") + .HasColumnType("uuid"); + + b.Property("IsEstimated") + .HasColumnType("boolean"); + + b.Property("IterationNumber") + .HasColumnType("integer"); + + b.Property("RequestId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ResultJson") + .HasColumnType("jsonb"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChatCompletionId") + .HasDatabaseName("IX_FunctionCallAudit_ChatCompletionId"); + + b.HasIndex("FunctionConfigurationId") + .HasDatabaseName("IX_FunctionCallAudit_FunctionConfigurationId"); + + b.HasIndex("FunctionExecutionId") + .HasDatabaseName("IX_FunctionCallAudit_FunctionExecutionId"); + + b.HasIndex("RequestId") + .HasDatabaseName("IX_FunctionCallAudit_RequestId"); + + b.HasIndex("VirtualKeyId") + .HasDatabaseName("IX_FunctionCallAudit_VirtualKeyId"); + + b.HasIndex("Timestamp", "EventType") + .HasDatabaseName("IX_FunctionCallAudit_TimestampEventType"); + + b.HasIndex("VirtualKeyId", "Timestamp") + .HasDatabaseName("IX_FunctionCallAudit_VirtualKeyTimestamp"); + + b.ToTable("FunctionCallAudits"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaseUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CacheTtlMinutes") + .HasColumnType("integer"); + + b.Property("ConfigurationName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultExecutionMode") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("MaxRetries") + .HasColumnType("integer"); + + b.Property("ParameterSchema") + .HasColumnType("jsonb"); + + b.Property("ProviderSettings") + .HasColumnType("jsonb"); + + b.Property("ProviderType") + .HasColumnType("integer"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled") + .HasDatabaseName("IX_FunctionConfiguration_IsEnabled"); + + b.HasIndex("ProviderType") + .HasDatabaseName("IX_FunctionConfiguration_ProviderType"); + + b.HasIndex("Purpose") + .HasDatabaseName("IX_FunctionConfiguration_Purpose"); + + b.ToTable("FunctionConfigurations"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionCost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BaseCost") + .HasColumnType("decimal(18,8)"); + + b.Property("CostName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CostPerExecution") + .HasColumnType("decimal(18,8)"); + + b.Property("CostPerMinute") + .HasColumnType("decimal(18,8)"); + + b.Property("CostPerResult") + .HasColumnType("decimal(18,8)"); + + b.Property("CostPerToken") + .HasColumnType("decimal(18,8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EffectiveDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PricingConfiguration") + .HasColumnType("jsonb"); + + b.Property("PricingModel") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("ProviderType") + .HasColumnType("integer"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("TieredPricing") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsActive") + .HasDatabaseName("IX_FunctionCost_IsActive"); + + b.HasIndex("EffectiveDate", "ExpiryDate") + .HasDatabaseName("IX_FunctionCost_EffectiveDates"); + + b.ToTable("FunctionCosts"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionCostMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FunctionConfigurationId") + .HasColumnType("integer"); + + b.Property("FunctionCostId") + .HasColumnType("integer"); + + b.Property("FunctionCostId1") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("FunctionCostId"); + + b.HasIndex("FunctionCostId1"); + + b.HasIndex("FunctionConfigurationId", "FunctionCostId") + .IsUnique() + .HasDatabaseName("IX_FunctionCostMapping_Unique"); + + b.ToTable("FunctionCostMappings"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaseUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FunctionAccountGroup") + .HasColumnType("smallint"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("KeyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Organization") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProviderType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProviderType") + .HasDatabaseName("IX_FunctionCredential_ProviderType"); + + b.HasIndex("ProviderType", "ApiKey") + .IsUnique() + .HasDatabaseName("IX_FunctionCredential_UniqueApiKeyPerProviderType") + .HasFilter("\"ApiKey\" IS NOT NULL"); + + b.HasIndex("ProviderType", "IsPrimary") + .IsUnique() + .HasDatabaseName("IX_FunctionCredential_OnePrimaryPerProviderType") + .HasFilter("\"IsPrimary\" = true"); + + b.ToTable("FunctionCredentials", t => + { + t.HasCheckConstraint("CK_FunctionCredential_AccountGroupRange", "\"FunctionAccountGroup\" >= 0 AND \"FunctionAccountGroup\" <= 32"); + + t.HasCheckConstraint("CK_FunctionCredential_PrimaryMustBeEnabled", "\"IsPrimary\" = false OR \"IsEnabled\" = true"); + }); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActualCost") + .HasColumnType("decimal(18,8)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CostCalculationDetails") + .HasColumnType("jsonb"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EstimatedCost") + .HasColumnType("decimal(18,8)"); + + b.Property("ExecutionMode") + .HasColumnType("integer"); + + b.Property("FunctionConfigurationId") + .HasColumnType("integer"); + + b.Property("LeaseExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LeasedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProgressPercentage") + .HasColumnType("integer"); + + b.Property("RequestJson") + .HasColumnType("jsonb"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("StatusMessage") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.Property("WebhookDelivered") + .HasColumnType("boolean"); + + b.Property("WebhookUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("FunctionConfigurationId"); + + b.HasIndex("RequestedAt") + .HasDatabaseName("IX_FunctionExecution_RequestedAt"); + + b.HasIndex("State") + .HasDatabaseName("IX_FunctionExecution_State"); + + b.HasIndex("VirtualKeyId") + .HasDatabaseName("IX_FunctionExecution_VirtualKeyId"); + + b.HasIndex("State", "NextRetryAt", "LeasedBy") + .HasDatabaseName("IX_FunctionExecution_AsyncProcessing"); + + b.ToTable("FunctionExecutions"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionExecutionAudit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cost") + .HasColumnType("decimal(18,8)"); + + b.Property("EventDetails") + .HasColumnType("jsonb"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FunctionExecutionId") + .HasColumnType("uuid"); + + b.Property("FunctionExecutionId1") + .HasColumnType("uuid"); + + b.Property("IsEstimated") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("VirtualKeyId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FunctionExecutionId1"); + + b.HasIndex("FunctionExecutionId", "Timestamp") + .HasDatabaseName("IX_FunctionExecutionAudit_ExecutionTimestamp"); + + b.ToTable("FunctionExecutionAudits"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.AsyncTask", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany() + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.BatchOperationHistory", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany() + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.BillingAuditEvent", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany() + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.MediaRecord", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany() + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.Model", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.ModelSeries", "Series") + .WithMany("Models") + .HasForeignKey("ModelSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelProviderMapping", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.ModelProviderTypeAssociation", "ModelProviderTypeAssociation") + .WithMany() + .HasForeignKey("ModelProviderTypeAssociationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ConduitLLM.Configuration.Entities.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ModelProviderTypeAssociation"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelProviderTypeAssociation", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.ModelCost", "ModelCost") + .WithMany("ModelProviderTypeAssociations") + .HasForeignKey("ModelCostId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ConduitLLM.Configuration.Entities.Model", "Model") + .WithMany("Identifiers") + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Model"); + + b.Navigation("ModelCost"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelSeries", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.ModelAuthor", "Author") + .WithMany("ModelSeries") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.Notification", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany("Notifications") + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.PricingAuditEvent", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany() + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ProviderKeyCredential", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.Provider", "Provider") + .WithMany("ProviderKeyCredentials") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.RequestLog", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany("RequestLogs") + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKey", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKeyGroup", "VirtualKeyGroup") + .WithMany("VirtualKeys") + .HasForeignKey("VirtualKeyGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("VirtualKeyGroup"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKeyGroup", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.MediaRetentionPolicy", "MediaRetentionPolicy") + .WithMany("VirtualKeyGroups") + .HasForeignKey("MediaRetentionPolicyId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("MediaRetentionPolicy"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKeyGroupTransaction", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKeyGroup", "VirtualKeyGroup") + .WithMany("Transactions") + .HasForeignKey("VirtualKeyGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("VirtualKeyGroup"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKeySpendHistory", b => + { + b.HasOne("ConduitLLM.Configuration.Entities.VirtualKey", "VirtualKey") + .WithMany("SpendHistory") + .HasForeignKey("VirtualKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("VirtualKey"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionCallAudit", b => + { + b.HasOne("ConduitLLM.Functions.Entities.FunctionConfiguration", "FunctionConfiguration") + .WithMany() + .HasForeignKey("FunctionConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ConduitLLM.Functions.Entities.FunctionExecution", "FunctionExecution") + .WithMany() + .HasForeignKey("FunctionExecutionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FunctionConfiguration"); + + b.Navigation("FunctionExecution"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionCostMapping", b => + { + b.HasOne("ConduitLLM.Functions.Entities.FunctionConfiguration", "FunctionConfiguration") + .WithMany("CostMappings") + .HasForeignKey("FunctionConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ConduitLLM.Functions.Entities.FunctionCost", "FunctionCost") + .WithMany() + .HasForeignKey("FunctionCostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ConduitLLM.Functions.Entities.FunctionCost", null) + .WithMany("FunctionMappings") + .HasForeignKey("FunctionCostId1"); + + b.Navigation("FunctionConfiguration"); + + b.Navigation("FunctionCost"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionExecution", b => + { + b.HasOne("ConduitLLM.Functions.Entities.FunctionConfiguration", "FunctionConfiguration") + .WithMany("Executions") + .HasForeignKey("FunctionConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FunctionConfiguration"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionExecutionAudit", b => + { + b.HasOne("ConduitLLM.Functions.Entities.FunctionExecution", "FunctionExecution") + .WithMany() + .HasForeignKey("FunctionExecutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ConduitLLM.Functions.Entities.FunctionExecution", null) + .WithMany("AuditEvents") + .HasForeignKey("FunctionExecutionId1") + .HasConstraintName("FK_FunctionExecutionAudits_FunctionExecutions_FunctionExecuti~1"); + + b.Navigation("FunctionExecution"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.MediaRetentionPolicy", b => + { + b.Navigation("VirtualKeyGroups"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.Model", b => + { + b.Navigation("Identifiers"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelAuthor", b => + { + b.Navigation("ModelSeries"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelCost", b => + { + b.Navigation("ModelProviderTypeAssociations"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.ModelSeries", b => + { + b.Navigation("Models"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.Provider", b => + { + b.Navigation("ProviderKeyCredentials"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKey", b => + { + b.Navigation("Notifications"); + + b.Navigation("RequestLogs"); + + b.Navigation("SpendHistory"); + }); + + modelBuilder.Entity("ConduitLLM.Configuration.Entities.VirtualKeyGroup", b => + { + b.Navigation("Transactions"); + + b.Navigation("VirtualKeys"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionConfiguration", b => + { + b.Navigation("CostMappings"); + + b.Navigation("Executions"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionCost", b => + { + b.Navigation("FunctionMappings"); + }); + + modelBuilder.Entity("ConduitLLM.Functions.Entities.FunctionExecution", b => + { + b.Navigation("AuditEvents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Shared/ConduitLLM.Configuration/Migrations/20260318191532_AddCachedTokenFieldsToRequestLogs.cs b/Shared/ConduitLLM.Configuration/Migrations/20260318191532_AddCachedTokenFieldsToRequestLogs.cs new file mode 100644 index 000000000..09d1c098e --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Migrations/20260318191532_AddCachedTokenFieldsToRequestLogs.cs @@ -0,0 +1,38 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ConduitLLM.Configuration.Migrations +{ + /// + public partial class AddCachedTokenFieldsToRequestLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CachedInputTokens", + table: "RequestLogs", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CachedWriteTokens", + table: "RequestLogs", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CachedInputTokens", + table: "RequestLogs"); + + migrationBuilder.DropColumn( + name: "CachedWriteTokens", + table: "RequestLogs"); + } + } +} diff --git a/Shared/ConduitLLM.Configuration/Migrations/20260319000000_RemoveUnusedCacheConfigurationTables.cs b/Shared/ConduitLLM.Configuration/Migrations/20260319000000_RemoveUnusedCacheConfigurationTables.cs new file mode 100644 index 000000000..dcdff4588 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Migrations/20260319000000_RemoveUnusedCacheConfigurationTables.cs @@ -0,0 +1,115 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ConduitLLM.Configuration.Migrations +{ + /// + public partial class RemoveUnusedCacheConfigurationTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CacheConfigurationAudits"); + + migrationBuilder.DropTable( + name: "CacheConfigurations"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CacheConfigurations", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Region = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + DefaultTtlSeconds = table.Column(type: "integer", nullable: false), + MaxTtlSeconds = table.Column(type: "integer", nullable: false), + MaxEntries = table.Column(type: "integer", nullable: false), + MaxMemoryBytes = table.Column(type: "bigint", nullable: false), + EvictionPolicy = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + UseMemoryCache = table.Column(type: "boolean", nullable: false), + UseDistributedCache = table.Column(type: "boolean", nullable: false), + EnableCompression = table.Column(type: "boolean", nullable: false), + CompressionThresholdBytes = table.Column(type: "integer", nullable: false), + Priority = table.Column(type: "integer", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + Notes = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + ExtendedConfig = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Version = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CacheConfigurations", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CacheConfigurationAudits", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Region = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Action = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + OldConfigJson = table.Column(type: "text", nullable: true), + NewConfigJson = table.Column(type: "text", nullable: true), + Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + ChangedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ChangedAt = table.Column(type: "timestamp with time zone", nullable: false), + ChangeSource = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + Success = table.Column(type: "boolean", nullable: false), + ErrorMessage = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CacheConfigurationAudits", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_CacheConfigurationAudits_ChangedAt", + table: "CacheConfigurationAudits", + column: "ChangedAt"); + + migrationBuilder.CreateIndex( + name: "IX_CacheConfigurationAudits_ChangedBy", + table: "CacheConfigurationAudits", + column: "ChangedBy"); + + migrationBuilder.CreateIndex( + name: "IX_CacheConfigurationAudits_Region", + table: "CacheConfigurationAudits", + column: "Region"); + + migrationBuilder.CreateIndex( + name: "IX_CacheConfigurationAudits_Region_ChangedAt", + table: "CacheConfigurationAudits", + columns: new[] { "Region", "ChangedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_CacheConfigurations_Region", + table: "CacheConfigurations", + column: "Region", + unique: true, + filter: "\"IsActive\" = true"); + + migrationBuilder.CreateIndex( + name: "IX_CacheConfigurations_Region_IsActive", + table: "CacheConfigurations", + columns: new[] { "Region", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_CacheConfigurations_UpdatedAt", + table: "CacheConfigurations", + column: "UpdatedAt"); + } + } +} diff --git a/Shared/ConduitLLM.Configuration/Migrations/ConduitDbContextModelSnapshot.cs b/Shared/ConduitLLM.Configuration/Migrations/ConduitDbContextModelSnapshot.cs index 27eeccd4f..57530842f 100644 --- a/Shared/ConduitLLM.Configuration/Migrations/ConduitDbContextModelSnapshot.cs +++ b/Shared/ConduitLLM.Configuration/Migrations/ConduitDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.3") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -282,159 +282,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BillingAuditEvents", (string)null); }); - modelBuilder.Entity("ConduitLLM.Configuration.Entities.CacheConfiguration", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CompressionThresholdBytes") - .HasColumnType("bigint"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("DefaultTtlSeconds") - .HasColumnType("integer"); - - b.Property("EnableCompression") - .HasColumnType("boolean"); - - b.Property("EnableDetailedStats") - .HasColumnType("boolean"); - - b.Property("Enabled") - .HasColumnType("boolean"); - - b.Property("EvictionPolicy") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("ExtendedConfig") - .HasColumnType("text"); - - b.Property("IsActive") - .HasColumnType("boolean"); - - b.Property("MaxEntries") - .HasColumnType("bigint"); - - b.Property("MaxMemoryBytes") - .HasColumnType("bigint"); - - b.Property("MaxTtlSeconds") - .HasColumnType("integer"); - - b.Property("Notes") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Priority") - .HasColumnType("integer"); - - b.Property("Region") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedBy") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("UseDistributedCache") - .HasColumnType("boolean"); - - b.Property("UseMemoryCache") - .HasColumnType("boolean"); - - b.Property("Version") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.HasKey("Id"); - - b.HasIndex("Region") - .IsUnique() - .HasFilter("\"IsActive\" = true"); - - b.HasIndex("UpdatedAt"); - - b.HasIndex("Region", "IsActive"); - - b.ToTable("CacheConfigurations"); - }); - - modelBuilder.Entity("ConduitLLM.Configuration.Entities.CacheConfigurationAudit", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Action") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ChangeSource") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("ChangedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ChangedBy") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("ErrorMessage") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("NewConfigJson") - .HasColumnType("text"); - - b.Property("OldConfigJson") - .HasColumnType("text"); - - b.Property("Reason") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Region") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Success") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.HasIndex("ChangedAt"); - - b.HasIndex("ChangedBy"); - - b.HasIndex("Region"); - - b.HasIndex("Region", "ChangedAt"); - - b.ToTable("CacheConfigurationAudits"); - }); - modelBuilder.Entity("ConduitLLM.Configuration.Entities.GlobalSetting", b => { b.Property("Id") @@ -1275,6 +1122,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CachedInputTokens") + .HasColumnType("integer"); + + b.Property("CachedWriteTokens") + .HasColumnType("integer"); + b.Property("ClientIp") .HasMaxLength(50) .HasColumnType("character varying(50)"); diff --git a/Shared/ConduitLLM.Configuration/ModelProviderMappingService.cs b/Shared/ConduitLLM.Configuration/ModelProviderMappingService.cs index 99ae303ea..365f1a5ce 100644 --- a/Shared/ConduitLLM.Configuration/ModelProviderMappingService.cs +++ b/Shared/ConduitLLM.Configuration/ModelProviderMappingService.cs @@ -1,4 +1,5 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Utilities; @@ -86,8 +87,9 @@ public async Task DeleteMappingAsync(int id) { try { - _logger.LogInformation("Getting all model-provider mappings"); - return await _repository.GetAllAsync(); + _logger.LogDebug("Getting all model-provider mappings"); + return await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _repository.GetPaginatedAsync); } catch (Exception ex) { @@ -100,7 +102,7 @@ public async Task DeleteMappingAsync(int id) { try { - _logger.LogInformation("Getting mapping by ID: {Id}", id); + _logger.LogDebug("Getting mapping by ID: {Id}", id); return await _repository.GetByIdAsync(id); } catch (Exception ex) @@ -119,7 +121,7 @@ public async Task DeleteMappingAsync(int id) try { - _logger.LogInformation("Getting mapping by model alias: {ModelAlias}", LoggingSanitizer.S(modelAlias)); + _logger.LogDebug("Getting mapping by model alias: {ModelAlias}", LoggingSanitizer.S(modelAlias)); return await _repository.GetByModelNameAsync(modelAlias); } catch (Exception ex) @@ -298,8 +300,9 @@ public async Task ProviderExistsByIdAsync(int providerId) { try { - _logger.LogInformation("Getting all available providers"); - var providers = await _providerRepository.GetAllAsync(); + _logger.LogDebug("Getting all available providers"); + var providers = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _providerRepository.GetPaginatedAsync); return providers.Select(p => (p.Id, p.ProviderName)).ToList(); } catch (Exception ex) diff --git a/Shared/ConduitLLM.Configuration/Models/CacheConfigurationModels.cs b/Shared/ConduitLLM.Configuration/Models/CacheConfigurationModels.cs deleted file mode 100644 index 37a937489..000000000 --- a/Shared/ConduitLLM.Configuration/Models/CacheConfigurationModels.cs +++ /dev/null @@ -1,173 +0,0 @@ -namespace ConduitLLM.Configuration.Models -{ - /// - /// Configuration for a cache region. - /// - /// - /// Defines the configuration for a specific cache region. Each region can have its own set of rules, - /// such as time-to-live (TTL), memory limits, and eviction policies. This allows for granular control - /// over caching behavior for different types of data throughout the application. - /// - /// - /// This class is typically used with dependency injection to configure caching services. - /// The settings can be populated from a configuration file (e.g., appsettings.json), - /// allowing for flexible cache management without changing the code. - /// - public class CacheRegionConfig - { - /// - /// Gets or sets the unique name for the cache region. - /// This name is used to identify and retrieve the configuration for a specific cache. - /// - /// "AuthTokens", "ModelMetadata" - public string Region { get; set; } = string.Empty; - - /// - /// Gets or sets a value indicating whether this cache region is active. - /// If set to false, any attempts to cache data in this region will be ignored. - /// - public bool Enabled { get; set; } = true; - - /// - /// Gets or sets the default time-to-live (TTL) for cache entries in this region. - /// If not specified, a system-wide default may be used. - /// - /// - /// This value determines how long an item will remain in the cache before it is automatically evicted. - /// - public TimeSpan? DefaultTTL { get; set; } - - /// - /// Gets or sets the maximum time-to-live (TTL) for cache entries in this region. - /// This can be used to enforce an upper limit on cache duration, even if a longer TTL is requested. - /// - public TimeSpan? MaxTTL { get; set; } - - /// - /// Gets or sets the maximum number of entries that can be stored in this cache region. - /// When this limit is reached, the cache will evict items based on the specified eviction policy. - /// - public long? MaxEntries { get; set; } - - /// - /// Gets or sets the maximum memory size in bytes that this cache region can consume. - /// When this limit is reached, the cache will evict items to free up memory. - /// - public long? MaxMemoryBytes { get; set; } - - /// - /// Gets or sets the priority of this cache region, typically on a scale of 0-100. - /// Higher priority regions may be less likely to have their items evicted during memory pressure. - /// - public int Priority { get; set; } = 50; - - /// - /// Gets or sets the eviction policy to use when the cache reaches its size or memory limit. - /// Common policies include "LRU" (Least Recently Used) and "LFU" (Least Frequently Used). - /// - /// "LRU", "LFU", "FIFO" - public string EvictionPolicy { get; set; } = "LRU"; - - /// - /// Gets or sets a value indicating whether to use an in-memory cache for this region. - /// In-memory caches are fast but are local to a single application instance. - /// - public bool UseMemoryCache { get; set; } = true; - - /// - /// Gets or sets a value indicating whether to use a distributed cache (e.g., Redis) for this region. - /// Distributed caches can be shared across multiple application instances. - /// - public bool UseDistributedCache { get; set; } = false; - - /// - /// Gets or sets a value indicating whether to collect detailed performance statistics for this cache region. - /// Enabling this may have a minor performance impact. - /// - public bool EnableDetailedStats { get; set; } = true; - - /// - /// Gets or sets a value indicating whether to compress cached items. - /// This can save memory but adds CPU overhead for compression and decompression. - /// - public bool EnableCompression { get; set; } = false; - - /// - /// Gets or sets the minimum size in bytes an item must be to be considered for compression. - /// Items smaller than this threshold will not be compressed, even if compression is enabled. - /// - public long? CompressionThresholdBytes { get; set; } - - /// - /// Gets or sets a dictionary for any custom or extended properties required by a specific cache implementation. - /// This provides a flexible way to add provider-specific settings. - /// - public Dictionary? ExtendedProperties { get; set; } - } - - /// - /// Provides a centralized list of well-known cache region names used throughout the application. - /// Using these constants helps prevent typos and ensures consistency when referring to cache regions. - /// - /// - /// Each constant represents a logical partition of the cache, intended for a specific type of data. - /// For example, `AuthTokens` is for caching authentication tokens, while `ModelMetadata` is for caching - /// metadata about machine learning models. - /// - public static class CacheRegions - { - /// Cache for virtual API keys and their mappings. - public const string VirtualKeys = "VirtualKeys"; - /// Cache for tracking API rate limit counters. - public const string RateLimits = "RateLimits"; - /// Cache for the health status of external providers. - public const string ProviderHealth = "ProviderHealth"; - /// Cache for metadata about available AI/ML models. - public const string ModelMetadata = "ModelMetadata"; - /// Cache for authentication and authorization tokens. - public const string AuthTokens = "AuthTokens"; - /// Cache for IP filter lists and rules. - public const string IpFilters = "IpFilters"; - /// Cache for the status and results of asynchronous tasks. - public const string AsyncTasks = "AsyncTasks"; - /// Cache for responses from external providers to reduce redundant calls. - public const string ProviderResponses = "ProviderResponses"; - /// Cache for text embeddings to speed up similarity searches. - public const string Embeddings = "Embeddings"; - /// Cache for application-wide global settings. - public const string GlobalSettings = "GlobalSettings"; - /// Cache for credentials used to access external providers. - public const string Providers = "Providers"; - /// Cache for the cost information of different AI/ML models. - public const string ModelCosts = "ModelCosts"; - /// Cache for audio stream data or metadata. - public const string AudioStreams = "AudioStreams"; - /// Cache for monitoring and telemetry data. - public const string Monitoring = "Monitoring"; - /// A default cache region for general-purpose caching. - public const string Default = "Default"; - - /// - /// Gets an array containing all defined cache region names. - /// This is useful for iterating over all regions, for example, to apply a configuration to all of them. - /// - public static string[] All => new[] - { - VirtualKeys, - RateLimits, - ProviderHealth, - ModelMetadata, - AuthTokens, - IpFilters, - AsyncTasks, - ProviderResponses, - Embeddings, - GlobalSettings, - Providers, - ModelCosts, - AudioStreams, - Monitoring, - Default - }; - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Options/PerformanceMonitoringOptions.cs b/Shared/ConduitLLM.Configuration/Options/PerformanceMonitoringOptions.cs new file mode 100644 index 000000000..900104887 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Options/PerformanceMonitoringOptions.cs @@ -0,0 +1,36 @@ +namespace ConduitLLM.Configuration.Options +{ + /// + /// Configuration options for performance monitoring thresholds and collection intervals + /// + public class PerformanceMonitoringOptions + { + // Metrics collection + public int MaxMetricsRetention { get; set; } = 10000; + public int MetricsWindowSeconds { get; set; } = 60; + public int AggregationIntervalSeconds { get; set; } = 30; + public int ThresholdCheckIntervalSeconds { get; set; } = 30; + + // Response time thresholds + public double ResponseTimeP95WarningMs { get; set; } = 1000; + public double ResponseTimeP99CriticalMs { get; set; } = 5000; + + // Error rate thresholds + public double ErrorRateWarningPercent { get; set; } = 1; + public double ErrorRateCriticalPercent { get; set; } = 5; + + // Request rate thresholds + public double RequestRateHighThreshold { get; set; } = 1000; + + // Database thresholds + public double DatabaseSlowQueryThresholdMs { get; set; } = 1000; + public int DatabaseSlowQueryCountThreshold { get; set; } = 10; + + // Cache thresholds + public double CacheHitRateLowThreshold { get; set; } = 80; + + // Connection pool thresholds + public double ConnectionPoolHighUtilizationThreshold { get; set; } = 80; + public int ConnectionPoolQueueWarningThreshold { get; set; } = 10; + } +} diff --git a/Shared/ConduitLLM.Configuration/ProviderService.cs b/Shared/ConduitLLM.Configuration/ProviderService.cs index 47c265b79..0ffcb2782 100644 --- a/Shared/ConduitLLM.Configuration/ProviderService.cs +++ b/Shared/ConduitLLM.Configuration/ProviderService.cs @@ -3,6 +3,7 @@ using ConduitLLM.Configuration.Events; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Exceptions; +using ConduitLLM.Configuration.Extensions; using MassTransit; using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; @@ -82,12 +83,13 @@ public async Task DeleteProviderAsync(int id) public async Task> GetAllProvidersAsync() { - _logger.LogInformation("Getting all providers"); - + _logger.LogDebug("Getting all providers"); + try { - var providers = await _repository.GetAllAsync(); - _logger.LogInformation("Retrieved {Count} providers", providers.Count()); + var providers = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _repository.GetPaginatedAsync); + _logger.LogDebug("Retrieved {Count} providers", providers.Count); return providers; } catch (Exception ex) @@ -99,18 +101,18 @@ public async Task> GetAllProvidersAsync() public async Task GetProviderByIdAsync(int id) { - _logger.LogInformation("Getting provider by ID: {Id}", id); - + _logger.LogDebug("Getting provider by ID: {Id}", id); + try { var provider = await _repository.GetByIdAsync(id); if (provider == null) { - _logger.LogInformation("Provider with ID {Id} not found", id); + _logger.LogDebug("Provider with ID {Id} not found", id); } else { - _logger.LogInformation("Retrieved provider {ProviderId}: {ProviderName}", provider.Id, provider.ProviderName); + _logger.LogDebug("Retrieved provider {ProviderId}: {ProviderName}", provider.Id, provider.ProviderName); } return provider; } @@ -129,13 +131,14 @@ public async Task> GetAllProvidersAsync() public async Task> GetAllEnabledProvidersAsync() { - _logger.LogInformation("Getting all enabled providers"); - + _logger.LogDebug("Getting all enabled providers"); + try { - var providers = await _repository.GetAllAsync(); + var providers = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _repository.GetPaginatedAsync); var enabledProviders = providers.Where(p => p.IsEnabled).ToList(); - _logger.LogInformation("Retrieved {Count} enabled providers out of {Total} total", enabledProviders.Count(), providers.Count()); + _logger.LogDebug("Retrieved {Count} enabled providers out of {Total} total", enabledProviders.Count, providers.Count); return enabledProviders; } catch (Exception ex) @@ -179,12 +182,13 @@ public async Task UpdateProviderAsync(Provider provider) // Provider Key Credential methods public async Task> GetAllCredentialsAsync() { - _logger.LogInformation("Getting all key credentials across all providers"); - + _logger.LogDebug("Getting all key credentials across all providers"); + try { - var credentials = await _keyRepository.GetAllAsync(); - _logger.LogInformation("Retrieved {Count} key credentials across all providers", credentials.Count()); + var credentials = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _keyRepository.GetPaginatedAsync); + _logger.LogDebug("Retrieved {Count} key credentials across all providers", credentials.Count); return credentials; } catch (Exception ex) @@ -196,11 +200,12 @@ public async Task> GetAllCredentialsAsync() public async Task> GetKeyCredentialsByProviderIdAsync(int providerId) { - _logger.LogInformation("Getting key credentials for provider ID: {ProviderId}", providerId); - + _logger.LogDebug("Getting key credentials for provider ID: {ProviderId}", providerId); + try { - return await _keyRepository.GetByProviderIdAsync(providerId); + return await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _keyRepository.GetByProviderIdPaginatedAsync, providerId); } catch (Exception ex) { @@ -211,7 +216,7 @@ public async Task> GetKeyCredentialsByProviderIdAsyn public async Task GetKeyCredentialByIdAsync(int keyId) { - _logger.LogInformation("Getting key credential by ID: {KeyId}", keyId); + _logger.LogDebug("Getting key credential by ID: {KeyId}", keyId); try { @@ -243,8 +248,9 @@ public async Task AddKeyCredentialAsync(int providerId, P } // If this is the first key or marked as primary, ensure it's the only primary - var existingKeys = await _keyRepository.GetByProviderIdAsync(providerId); - if (existingKeys.Count() == 0 || keyCredential.IsPrimary) + var existingKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _keyRepository.GetByProviderIdPaginatedAsync, providerId); + if (!existingKeys.Any() || keyCredential.IsPrimary) { // Unset any existing primary keys foreach (var existingKey in existingKeys.Where(k => k.IsPrimary)) @@ -256,9 +262,10 @@ public async Task AddKeyCredentialAsync(int providerId, P } keyCredential.ProviderId = providerId; - + // Check if this API key already exists for this provider - var allProviderKeys = await _keyRepository.GetByProviderIdAsync(providerId); + var allProviderKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _keyRepository.GetByProviderIdPaginatedAsync, providerId); if (allProviderKeys.Any(k => k.ApiKey == keyCredential.ApiKey)) { var provider = await _repository.GetByIdAsync(providerId); @@ -275,23 +282,25 @@ public async Task AddKeyCredentialAsync(int providerId, P try { - var created = await _keyRepository.CreateAsync(keyCredential); - - _logger.LogInformation("Successfully added key credential {KeyId} for provider {ProviderId}", - created.Id, providerId); - + var createdId = await _keyRepository.CreateAsync(keyCredential); + + // After CreateAsync, the keyCredential entity has its Id populated + // and any auto-set properties (like IsPrimary) are updated + _logger.LogInformation("Successfully added key credential {KeyId} for provider {ProviderId}", + createdId, providerId); + // Publish domain event await _publishEndpoint.Publish(new ProviderKeyCredentialCreated { - KeyId = created.Id, + KeyId = createdId, ProviderId = providerId, - IsPrimary = created.IsPrimary, - IsEnabled = created.IsEnabled, + IsPrimary = keyCredential.IsPrimary, + IsEnabled = keyCredential.IsEnabled, Timestamp = DateTime.UtcNow, CorrelationId = Guid.NewGuid() }); - - return created; + + return keyCredential; } catch (DbUpdateException dbEx) { @@ -330,7 +339,7 @@ public async Task UpdateKeyCredentialAsync(int keyId, ProviderKeyCredentia } _logger.LogInformation("Updating key credential ID: {KeyId}", keyId); - + try { // Validate the update @@ -343,28 +352,42 @@ public async Task UpdateKeyCredentialAsync(int keyId, ProviderKeyCredentia } } + // Fetch the existing entity to track actual changes + var existing = await _keyRepository.GetByIdAsync(keyId); + if (existing == null) + { + _logger.LogWarning("Failed to update key credential {KeyId} - not found", keyId); + return false; + } + + var changedProperties = new List(); + if (keyCredential.ApiKey != existing.ApiKey) changedProperties.Add(nameof(ProviderKeyCredential.ApiKey)); + if (keyCredential.BaseUrl != existing.BaseUrl) changedProperties.Add(nameof(ProviderKeyCredential.BaseUrl)); + if (keyCredential.Organization != existing.Organization) changedProperties.Add(nameof(ProviderKeyCredential.Organization)); + if (keyCredential.IsEnabled != existing.IsEnabled) changedProperties.Add(nameof(ProviderKeyCredential.IsEnabled)); + if (keyCredential.IsPrimary != existing.IsPrimary) changedProperties.Add(nameof(ProviderKeyCredential.IsPrimary)); + if (keyCredential.KeyName != existing.KeyName) changedProperties.Add(nameof(ProviderKeyCredential.KeyName)); + if (keyCredential.ProviderAccountGroup != existing.ProviderAccountGroup) changedProperties.Add(nameof(ProviderKeyCredential.ProviderAccountGroup)); + keyCredential.Id = keyId; var success = await _keyRepository.UpdateAsync(keyCredential); - + if (success) { - _logger.LogInformation("Successfully updated key credential {KeyId}", keyId); - - // Publish domain event + _logger.LogInformation("Successfully updated key credential {KeyId}, changed: [{ChangedProperties}]", + keyId, string.Join(", ", changedProperties)); + + // Publish domain event with actual changed properties await _publishEndpoint.Publish(new ProviderKeyCredentialUpdated { KeyId = keyId, ProviderId = keyCredential.ProviderId, - ChangedProperties = new[] { "ApiKey", "BaseUrl", "ApiVersion", "IsEnabled", "IsPrimary" }, // TODO: Track actual changes + ChangedProperties = changedProperties.ToArray(), Timestamp = DateTime.UtcNow, CorrelationId = Guid.NewGuid() }); } - else - { - _logger.LogWarning("Failed to update key credential {KeyId} - not found", keyId); - } - + return success; } catch (Exception ex) diff --git a/Shared/ConduitLLM.Configuration/Repositories/AsyncTaskRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/AsyncTaskRepository.cs index eb77c51c1..41638ae5e 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/AsyncTaskRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/AsyncTaskRepository.cs @@ -1,19 +1,18 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Utilities; + using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Configuration.Repositories { /// /// Repository implementation for managing async tasks. + /// Extends RepositoryBase for standard CRUD operations. /// - public class AsyncTaskRepository : IAsyncTaskRepository + public class AsyncTaskRepository : RepositoryBase, IAsyncTaskRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - /// /// Initializes a new instance of the class. /// @@ -22,232 +21,200 @@ public class AsyncTaskRepository : IAsyncTaskRepository public AsyncTaskRepository( IDbContextFactory dbContextFactory, ILogger logger) + : base(dbContextFactory, logger) { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - public async Task GetByIdAsync(string taskId, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(taskId)) - { - throw new ArgumentNullException(nameof(taskId)); - } - - try - { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await context.AsyncTasks - .AsNoTracking() - .FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting async task by ID: {TaskId}", taskId); - throw; - } } /// - public async Task> GetByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default) + protected override DbSet GetDbSet(ConduitDbContext context) { - try - { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await context.AsyncTasks - .AsNoTracking() - .Where(t => t.VirtualKeyId == virtualKeyId) - .OrderByDescending(t => t.CreatedAt) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting async tasks by virtual key ID: {VirtualKeyId}", virtualKeyId); - throw; - } + return context.AsyncTasks; } /// - public async Task> GetActiveByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default) + protected override IQueryable ApplyDefaultOrdering(IQueryable query) { - try - { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await context.AsyncTasks - .AsNoTracking() - .Where(t => t.VirtualKeyId == virtualKeyId && !t.IsArchived) - .OrderByDescending(t => t.CreatedAt) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting active async tasks by virtual key ID: {VirtualKeyId}", virtualKeyId); - throw; - } + return query.OrderByDescending(t => t.CreatedAt); } /// - public async Task CreateAsync(AsyncTask task, CancellationToken cancellationToken = default) + public override async Task CreateAsync(AsyncTask entity, CancellationToken cancellationToken = default) { - if (task == null) - { - throw new ArgumentNullException(nameof(task)); - } + ArgumentNullException.ThrowIfNull(entity); try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - task.CreatedAt = DateTime.UtcNow; - task.UpdatedAt = DateTime.UtcNow; + var taskId = await base.CreateAsync(entity, cancellationToken); - context.AsyncTasks.Add(task); - await context.SaveChangesAsync(cancellationToken); + Logger.LogInformation("Created async task: {TaskId} of type {TaskType} for virtual key {VirtualKeyId}", + entity.Id, entity.Type, entity.VirtualKeyId); - _logger.LogInformation("Created async task: {TaskId} of type {TaskType} for virtual key {VirtualKeyId}", - task.Id, task.Type, task.VirtualKeyId); - - return task.Id; + return taskId; } catch (DbUpdateException ex) { - _logger.LogError(ex, "Database error creating async task: {Task}", - LogSanitizer.SanitizeObject(task)); + Logger.LogError(ex, "Database error creating async task: {Task}", + LoggingSanitizer.S(entity)); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error creating async task: {Task}", - LogSanitizer.SanitizeObject(task)); + Logger.LogError(ex, "Error creating async task: {Task}", + LoggingSanitizer.S(entity)); throw; } } /// - public async Task UpdateAsync(AsyncTask task, CancellationToken cancellationToken = default) + public override async Task UpdateAsync(AsyncTask entity, CancellationToken cancellationToken = default) { - if (task == null) - { - throw new ArgumentNullException(nameof(task)); - } + ArgumentNullException.ThrowIfNull(entity); try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - task.UpdatedAt = DateTime.UtcNow; - - context.AsyncTasks.Update(task); - var affected = await context.SaveChangesAsync(cancellationToken); + var result = await base.UpdateAsync(entity, cancellationToken); - if (affected > 0) + if (result) { - _logger.LogInformation("Updated async task: {TaskId} with state {State}", - task.Id, task.State); + Logger.LogInformation("Updated async task: {TaskId} with state {State}", + entity.Id, entity.State); } else { - _logger.LogWarning("No rows affected when updating async task: {TaskId}", task.Id); + Logger.LogWarning("No rows affected when updating async task: {TaskId}", entity.Id); } - return affected > 0; + return result; } catch (DbUpdateConcurrencyException ex) { - _logger.LogWarning(ex, "Concurrency conflict updating async task: {TaskId}", task.Id); + Logger.LogWarning(ex, "Concurrency conflict updating async task: {TaskId}", entity.Id); return false; } catch (DbUpdateException ex) { - _logger.LogError(ex, "Database error updating async task: {TaskId}", task.Id); + Logger.LogError(ex, "Database error updating async task: {TaskId}", entity.Id); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error updating async task: {TaskId}", task.Id); + Logger.LogError(ex, "Error updating async task: {TaskId}", entity.Id); throw; } } /// - public async Task DeleteAsync(string taskId, CancellationToken cancellationToken = default) + public override async Task DeleteAsync(string id, CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(taskId)) + if (string.IsNullOrWhiteSpace(id)) { - throw new ArgumentNullException(nameof(taskId)); + throw new ArgumentNullException(nameof(id)); } try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var task = await context.AsyncTasks.FindAsync(new object[] { taskId }, cancellationToken); - if (task == null) - { - return false; - } + var result = await base.DeleteAsync(id, cancellationToken); - context.AsyncTasks.Remove(task); - var affected = await context.SaveChangesAsync(cancellationToken); - - if (affected > 0) + if (result) { - _logger.LogInformation("Deleted async task: {TaskId}", taskId); + Logger.LogInformation("Deleted async task: {TaskId}", id); } - return affected > 0; + return result; } catch (DbUpdateException ex) { - _logger.LogError(ex, "Database error deleting async task: {TaskId}", taskId); + Logger.LogError(ex, "Database error deleting async task: {TaskId}", id); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error deleting async task: {TaskId}", taskId); + Logger.LogError(ex, "Error deleting async task: {TaskId}", id); throw; } } /// - public async Task ArchiveOldTasksAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + public async Task> GetByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default) { try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var cutoffDate = DateTime.UtcNow.Subtract(olderThan); - - var completedStates = new[] { 2, 3, 4, 5 }; // Completed, Failed, Cancelled, TimedOut - - var tasksToArchive = await context.AsyncTasks - .Where(t => !t.IsArchived && - t.CompletedAt.HasValue && - t.CompletedAt.Value < cutoffDate && - completedStates.Contains(t.State)) - .ToListAsync(cancellationToken); - - foreach (var task in tasksToArchive) + return await ExecuteAsync(async context => { - task.IsArchived = true; - task.ArchivedAt = DateTime.UtcNow; - task.UpdatedAt = DateTime.UtcNow; - } + return await context.AsyncTasks + .AsNoTracking() + .Where(t => t.VirtualKeyId == virtualKeyId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken); + }, cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting async tasks by virtual key ID: {VirtualKeyId}", virtualKeyId); + throw; + } + } - var affected = await context.SaveChangesAsync(cancellationToken); + /// + public async Task> GetActiveByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => + { + return await context.AsyncTasks + .AsNoTracking() + .Where(t => t.VirtualKeyId == virtualKeyId && !t.IsArchived) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken); + }, cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting active async tasks by virtual key ID: {VirtualKeyId}", virtualKeyId); + throw; + } + } - if (affected > 0) + /// + public async Task ArchiveOldTasksAsync(TimeSpan olderThan, CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => { - _logger.LogInformation("Archived {Count} completed tasks older than {OlderThan}", - affected, olderThan); - } + var cutoffDate = DateTime.UtcNow.Subtract(olderThan); + + var completedStates = new[] { 2, 3, 4, 5 }; // Completed, Failed, Cancelled, TimedOut - return affected; + var tasksToArchive = await context.AsyncTasks + .Where(t => !t.IsArchived && + t.CompletedAt.HasValue && + t.CompletedAt.Value < cutoffDate && + completedStates.Contains(t.State)) + .ToListAsync(cancellationToken); + + foreach (var task in tasksToArchive) + { + task.IsArchived = true; + task.ArchivedAt = DateTime.UtcNow; + task.UpdatedAt = DateTime.UtcNow; + } + + var affected = await context.SaveChangesAsync(cancellationToken); + + if (affected > 0) + { + Logger.LogInformation("Archived {Count} completed tasks older than {OlderThan}", + affected, olderThan); + } + + return affected; + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error archiving old tasks"); + Logger.LogError(ex, "Error archiving old tasks"); throw; } } @@ -257,20 +224,21 @@ public async Task> GetTasksForCleanupAsync(TimeSpan archivedOlde { try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var cutoffDate = DateTime.UtcNow.Subtract(archivedOlderThan); - - return await context.AsyncTasks - .AsNoTracking() - .Where(t => t.IsArchived && t.ArchivedAt.HasValue && t.ArchivedAt.Value < cutoffDate) - .OrderBy(t => t.ArchivedAt) - .Take(limit) - .ToListAsync(cancellationToken); + return await ExecuteAsync(async context => + { + var cutoffDate = DateTime.UtcNow.Subtract(archivedOlderThan); + + return await context.AsyncTasks + .AsNoTracking() + .Where(t => t.IsArchived && t.ArchivedAt.HasValue && t.ArchivedAt.Value < cutoffDate) + .OrderBy(t => t.ArchivedAt) + .Take(limit) + .ToListAsync(cancellationToken); + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error getting tasks for cleanup"); + Logger.LogError(ex, "Error getting tasks for cleanup"); throw; } } @@ -278,43 +246,41 @@ public async Task> GetTasksForCleanupAsync(TimeSpan archivedOlde /// public async Task BulkDeleteAsync(IEnumerable taskIds, CancellationToken cancellationToken = default) { - if (taskIds == null) - { - throw new ArgumentNullException(nameof(taskIds)); - } + ArgumentNullException.ThrowIfNull(taskIds); var taskIdList = taskIds.ToList(); - if (taskIdList.Count() == 0) + if (taskIdList.Count == 0) { return 0; } try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var tasksToDelete = await context.AsyncTasks - .Where(t => taskIdList.Contains(t.Id)) - .ToListAsync(cancellationToken); + return await ExecuteAsync(async context => + { + var tasksToDelete = await context.AsyncTasks + .Where(t => taskIdList.Contains(t.Id)) + .ToListAsync(cancellationToken); - context.AsyncTasks.RemoveRange(tasksToDelete); - var affected = await context.SaveChangesAsync(cancellationToken); + context.AsyncTasks.RemoveRange(tasksToDelete); + var affected = await context.SaveChangesAsync(cancellationToken); - if (affected > 0) - { - _logger.LogInformation("Bulk deleted {Count} async tasks", affected); - } + if (affected > 0) + { + Logger.LogInformation("Bulk deleted {Count} async tasks", affected); + } - return affected; + return affected; + }, cancellationToken); } catch (DbUpdateException ex) { - _logger.LogError(ex, "Database error bulk deleting async tasks"); + Logger.LogError(ex, "Database error bulk deleting async tasks"); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error bulk deleting async tasks"); + Logger.LogError(ex, "Error bulk deleting async tasks"); throw; } } @@ -324,27 +290,28 @@ public async Task> GetPendingTasksAsync(string? taskType = null, { try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var now = DateTime.UtcNow; - var query = context.AsyncTasks - .AsNoTracking() - .Where(t => t.State == 0 && !t.IsArchived && - (t.LeasedBy == null || t.LeaseExpiryTime == null || t.LeaseExpiryTime < now)); - - if (!string.IsNullOrEmpty(taskType)) + return await ExecuteAsync(async context => { - query = query.Where(t => t.Type == taskType); - } - - return await query - .OrderBy(t => t.CreatedAt) - .Take(limit) - .ToListAsync(cancellationToken); + var now = DateTime.UtcNow; + var query = context.AsyncTasks + .AsNoTracking() + .Where(t => t.State == 0 && !t.IsArchived && + (t.LeasedBy == null || t.LeaseExpiryTime == null || t.LeaseExpiryTime < now)); + + if (!string.IsNullOrEmpty(taskType)) + { + query = query.Where(t => t.Type == taskType); + } + + return await query + .OrderBy(t => t.CreatedAt) + .Take(limit) + .ToListAsync(cancellationToken); + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error getting pending tasks"); + Logger.LogError(ex, "Error getting pending tasks"); throw; } } @@ -359,44 +326,46 @@ public async Task> GetPendingTasksAsync(string? taskType = null, try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); - - var now = DateTime.UtcNow; - var query = context.AsyncTasks - .Where(t => t.State == 0 && !t.IsArchived && - (t.LeasedBy == null || t.LeaseExpiryTime == null || t.LeaseExpiryTime < now) && - (t.NextRetryAt == null || t.NextRetryAt <= now)); - - if (!string.IsNullOrEmpty(taskType)) + return await ExecuteAsync(async context => { - query = query.Where(t => t.Type == taskType); - } + using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); - // Use row-level locking to prevent concurrent access - var task = await query - .OrderBy(t => t.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); + var now = DateTime.UtcNow; + var query = context.AsyncTasks + .Where(t => t.State == 0 && !t.IsArchived && + (t.LeasedBy == null || t.LeaseExpiryTime == null || t.LeaseExpiryTime < now) && + (t.NextRetryAt == null || t.NextRetryAt <= now)); - if (task != null) - { - task.LeasedBy = workerId; - task.LeaseExpiryTime = now.Add(leaseDuration); - task.UpdatedAt = now; - task.Version++; + if (!string.IsNullOrEmpty(taskType)) + { + query = query.Where(t => t.Type == taskType); + } - await context.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + // Use row-level locking to prevent concurrent access + var task = await query + .OrderBy(t => t.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); - _logger.LogInformation("Worker {WorkerId} leased task {TaskId} until {ExpiryTime}", - workerId, task.Id, task.LeaseExpiryTime); - } + if (task != null) + { + task.LeasedBy = workerId; + task.LeaseExpiryTime = now.Add(leaseDuration); + task.UpdatedAt = now; + task.Version++; + + await context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); - return task; + Logger.LogInformation("Worker {WorkerId} leased task {TaskId} until {ExpiryTime}", + workerId, task.Id, task.LeaseExpiryTime); + } + + return task; + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error leasing next pending task for worker {WorkerId}", workerId); + Logger.LogError(ex, "Error leasing next pending task for worker {WorkerId}", workerId); throw; } } @@ -416,34 +385,35 @@ public async Task ReleaseLeaseAsync(string taskId, string workerId, Cancel try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var task = await context.AsyncTasks - .FirstOrDefaultAsync(t => t.Id == taskId && t.LeasedBy == workerId, cancellationToken); - - if (task == null) + return await ExecuteAsync(async context => { - _logger.LogWarning("Task {TaskId} not found or not leased by worker {WorkerId}", taskId, workerId); - return false; - } + var task = await context.AsyncTasks + .FirstOrDefaultAsync(t => t.Id == taskId && t.LeasedBy == workerId, cancellationToken); - task.LeasedBy = null; - task.LeaseExpiryTime = null; - task.UpdatedAt = DateTime.UtcNow; - task.Version++; + if (task == null) + { + Logger.LogWarning("Task {TaskId} not found or not leased by worker {WorkerId}", taskId, workerId); + return false; + } - var affected = await context.SaveChangesAsync(cancellationToken); - - if (affected > 0) - { - _logger.LogInformation("Released lease on task {TaskId} by worker {WorkerId}", taskId, workerId); - } + task.LeasedBy = null; + task.LeaseExpiryTime = null; + task.UpdatedAt = DateTime.UtcNow; + task.Version++; + + var affected = await context.SaveChangesAsync(cancellationToken); + + if (affected > 0) + { + Logger.LogInformation("Released lease on task {TaskId} by worker {WorkerId}", taskId, workerId); + } - return affected > 0; + return affected > 0; + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error releasing lease on task {TaskId} by worker {WorkerId}", taskId, workerId); + Logger.LogError(ex, "Error releasing lease on task {TaskId} by worker {WorkerId}", taskId, workerId); throw; } } @@ -463,38 +433,39 @@ public async Task ExtendLeaseAsync(string taskId, string workerId, TimeSpa try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var now = DateTime.UtcNow; - var task = await context.AsyncTasks - .FirstOrDefaultAsync(t => t.Id == taskId && t.LeasedBy == workerId && - t.LeaseExpiryTime != null && t.LeaseExpiryTime > now, - cancellationToken); - - if (task == null) + return await ExecuteAsync(async context => { - _logger.LogWarning("Task {TaskId} not found, not leased by worker {WorkerId}, or lease expired", - taskId, workerId); - return false; - } + var now = DateTime.UtcNow; + var task = await context.AsyncTasks + .FirstOrDefaultAsync(t => t.Id == taskId && t.LeasedBy == workerId && + t.LeaseExpiryTime != null && t.LeaseExpiryTime > now, + cancellationToken); + + if (task == null) + { + Logger.LogWarning("Task {TaskId} not found, not leased by worker {WorkerId}, or lease expired", + taskId, workerId); + return false; + } + + task.LeaseExpiryTime = now.Add(extension); + task.UpdatedAt = now; + task.Version++; - task.LeaseExpiryTime = now.Add(extension); - task.UpdatedAt = now; - task.Version++; + var affected = await context.SaveChangesAsync(cancellationToken); - var affected = await context.SaveChangesAsync(cancellationToken); - - if (affected > 0) - { - _logger.LogInformation("Extended lease on task {TaskId} by worker {WorkerId} until {ExpiryTime}", - taskId, workerId, task.LeaseExpiryTime); - } + if (affected > 0) + { + Logger.LogInformation("Extended lease on task {TaskId} by worker {WorkerId} until {ExpiryTime}", + taskId, workerId, task.LeaseExpiryTime); + } - return affected > 0; + return affected > 0; + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error extending lease on task {TaskId} by worker {WorkerId}", taskId, workerId); + Logger.LogError(ex, "Error extending lease on task {TaskId} by worker {WorkerId}", taskId, workerId); throw; } } @@ -504,22 +475,23 @@ public async Task> GetExpiredLeaseTasksAsync(int limit = 100, Ca { try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var now = DateTime.UtcNow; - return await context.AsyncTasks - .AsNoTracking() - .Where(t => t.LeasedBy != null && - t.LeaseExpiryTime != null && - t.LeaseExpiryTime < now && - t.State == 1) // Processing state - .OrderBy(t => t.LeaseExpiryTime) - .Take(limit) - .ToListAsync(cancellationToken); + return await ExecuteAsync(async context => + { + var now = DateTime.UtcNow; + return await context.AsyncTasks + .AsNoTracking() + .Where(t => t.LeasedBy != null && + t.LeaseExpiryTime != null && + t.LeaseExpiryTime < now && + t.State == 1) // Processing state + .OrderBy(t => t.LeaseExpiryTime) + .Take(limit) + .ToListAsync(cancellationToken); + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error getting expired lease tasks"); + Logger.LogError(ex, "Error getting expired lease tasks"); throw; } } @@ -527,52 +499,50 @@ public async Task> GetExpiredLeaseTasksAsync(int limit = 100, Ca /// public async Task UpdateWithVersionCheckAsync(AsyncTask task, int expectedVersion, CancellationToken cancellationToken = default) { - if (task == null) - { - throw new ArgumentNullException(nameof(task)); - } + ArgumentNullException.ThrowIfNull(task); try { - using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Check version before updating - var currentVersion = await context.AsyncTasks - .Where(t => t.Id == task.Id) - .Select(t => t.Version) - .FirstOrDefaultAsync(cancellationToken); - - if (currentVersion != expectedVersion) + return await ExecuteAsync(async context => { - _logger.LogWarning("Version mismatch for task {TaskId}. Expected {ExpectedVersion}, found {CurrentVersion}", - task.Id, expectedVersion, currentVersion); - return false; - } + // Check version before updating + var currentVersion = await context.AsyncTasks + .Where(t => t.Id == task.Id) + .Select(t => t.Version) + .FirstOrDefaultAsync(cancellationToken); + + if (currentVersion != expectedVersion) + { + Logger.LogWarning("Version mismatch for task {TaskId}. Expected {ExpectedVersion}, found {CurrentVersion}", + task.Id, expectedVersion, currentVersion); + return false; + } - task.UpdatedAt = DateTime.UtcNow; - task.Version = expectedVersion + 1; - - context.AsyncTasks.Update(task); - var affected = await context.SaveChangesAsync(cancellationToken); + task.UpdatedAt = DateTime.UtcNow; + task.Version = expectedVersion + 1; - if (affected > 0) - { - _logger.LogInformation("Updated task {TaskId} with version check (version {OldVersion} -> {NewVersion})", - task.Id, expectedVersion, task.Version); - } + context.AsyncTasks.Update(task); + var affected = await context.SaveChangesAsync(cancellationToken); + + if (affected > 0) + { + Logger.LogInformation("Updated task {TaskId} with version check (version {OldVersion} -> {NewVersion})", + task.Id, expectedVersion, task.Version); + } - return affected > 0; + return affected > 0; + }, cancellationToken); } catch (DbUpdateConcurrencyException ex) { - _logger.LogWarning(ex, "Concurrency conflict updating task {TaskId} with version check", task.Id); + Logger.LogWarning(ex, "Concurrency conflict updating task {TaskId} with version check", task.Id); return false; } catch (Exception ex) { - _logger.LogError(ex, "Error updating task {TaskId} with version check", task.Id); + Logger.LogError(ex, "Error updating task {TaskId} with version check", task.Id); throw; } } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/BatchOperationHistoryRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/BatchOperationHistoryRepository.cs index c89ea0a3b..8a84d5ac1 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/BatchOperationHistoryRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/BatchOperationHistoryRepository.cs @@ -6,51 +6,68 @@ namespace ConduitLLM.Configuration.Repositories { /// - /// Repository for batch operation history + /// Repository for batch operation history. + /// Inherits common CRUD operations from RepositoryBase. /// - public class BatchOperationHistoryRepository : IBatchOperationHistoryRepository + public class BatchOperationHistoryRepository : RepositoryBase, IBatchOperationHistoryRepository { - private readonly ConduitDbContext _context; - private readonly ILogger _logger; - public BatchOperationHistoryRepository( - ConduitDbContext context, + IDbContextFactory dbContextFactory, ILogger logger) + : base(dbContextFactory, logger) + { + } + + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.BatchOperationHistory; + + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) { - _context = context ?? throw new ArgumentNullException(nameof(context)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + return query.Include(h => h.VirtualKey); } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderByDescending(h => h.StartedAt); + } + + /// public async Task SaveAsync(BatchOperationHistory history) { - try + return await ExecuteAsync(async context => { - _context.BatchOperationHistory.Add(history); - await _context.SaveChangesAsync(); - - _logger.LogInformation( + GetDbSet(context).Add(history); + await context.SaveChangesAsync(); + + Logger.LogInformation( "Saved batch operation history for {OperationId} - Type: {OperationType}, Status: {Status}", history.OperationId, history.OperationType, history.Status); - + return history; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving batch operation history for {OperationId}", history.OperationId); - throw; - } + }, operationName: "saving"); } - public async Task UpdateAsync(BatchOperationHistory history) + /// + /// Updates an existing batch operation history record by looking up the existing record + /// via OperationId, applying field-level changes, and saving. + /// + /// + /// This is an explicit interface implementation because it differs from the base + /// โ€” it applies selective + /// field updates and returns the updated entity (or null if not found). + /// + async Task IBatchOperationHistoryRepository.UpdateAsync(BatchOperationHistory history) { - try + return await ExecuteAsync(async context => { - var existing = await _context.BatchOperationHistory + var existing = await GetDbSet(context) .FirstOrDefaultAsync(h => h.OperationId == history.OperationId); - + if (existing == null) { - _logger.LogWarning("Batch operation history not found for update: {OperationId}", history.OperationId); + Logger.LogWarning("Batch operation history not found for update: {OperationId}", history.OperationId); return null; } @@ -68,118 +85,166 @@ public async Task SaveAsync(BatchOperationHistory history existing.CheckpointData = history.CheckpointData; existing.LastProcessedIndex = history.LastProcessedIndex; - await _context.SaveChangesAsync(); - - _logger.LogInformation( + await context.SaveChangesAsync(); + + Logger.LogInformation( "Updated batch operation history for {OperationId} - Status: {Status}", history.OperationId, history.Status); - + return existing; - } - catch (Exception ex) + }, operationName: "updating"); + } + + /// + /// Gets a batch operation history by its operation ID. + /// Overrides the base implementation because the primary key property (OperationId) + /// differs from the IEntity.Id alias, which is [NotMapped] and cannot be used in LINQ-to-SQL. + /// + public override async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Error updating batch operation history for {OperationId}", history.OperationId); - throw; - } + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + return await query.FirstOrDefaultAsync(e => e.OperationId == id, cancellationToken); + }, cancellationToken, $"getting by ID {id}"); + } + + /// + /// Explicit interface implementation for + /// which lacks a CancellationToken parameter. + /// + async Task IBatchOperationHistoryRepository.GetByIdAsync(string operationId) + { + return await GetByIdAsync(operationId); } - public async Task GetByIdAsync(string operationId) + /// + /// Overrides base implementation to use OperationId (the mapped PK property) + /// instead of Id (the [NotMapped] alias) in LINQ queries. + /// + public override async Task ExistsAsync(string id, CancellationToken cancellationToken = default) { - return await _context.BatchOperationHistory - .Include(h => h.VirtualKey) - .FirstOrDefaultAsync(h => h.OperationId == operationId); + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .AnyAsync(e => e.OperationId == id, cancellationToken), + cancellationToken, $"checking existence of ID {id}"); } + /// public async Task> GetByVirtualKeyIdAsync(int virtualKeyId, int skip = 0, int take = 20) { - return await _context.BatchOperationHistory - .Where(h => h.VirtualKeyId == virtualKeyId) - .OrderByDescending(h => h.StartedAt) - .Skip(skip) - .Take(take) - .ToListAsync(); + return await ExecuteAsync(async context => + { + return await GetDbSet(context) + .AsNoTracking() + .Where(h => h.VirtualKeyId == virtualKeyId) + .OrderByDescending(h => h.StartedAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + }, operationName: "getting by virtual key ID"); } + /// public async Task> GetRecentOperationsAsync(int take = 20) { - return await _context.BatchOperationHistory - .OrderByDescending(h => h.StartedAt) - .Take(take) - .Include(h => h.VirtualKey) - .ToListAsync(); + return await ExecuteAsync(async context => + { + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + return await query + .OrderByDescending(h => h.StartedAt) + .Take(take) + .ToListAsync(); + }, operationName: "getting recent operations"); } + /// public async Task> GetResumableOperationsAsync(int virtualKeyId) { - return await _context.BatchOperationHistory - .Where(h => h.VirtualKeyId == virtualKeyId && - h.CanResume && - (h.Status == "Cancelled" || h.Status == "Failed" || h.Status == "PartiallyCompleted")) - .OrderByDescending(h => h.StartedAt) - .ToListAsync(); + return await ExecuteAsync(async context => + { + return await GetDbSet(context) + .AsNoTracking() + .Where(h => h.VirtualKeyId == virtualKeyId && + h.CanResume && + (h.Status == "Cancelled" || h.Status == "Failed" || h.Status == "PartiallyCompleted")) + .OrderByDescending(h => h.StartedAt) + .ToListAsync(); + }, operationName: "getting resumable operations"); } + /// public async Task DeleteOldHistoryAsync(DateTime olderThan) { - var toDelete = await _context.BatchOperationHistory - .Where(h => h.StartedAt < olderThan) - .ToListAsync(); - - if (toDelete.Count() > 0) + return await ExecuteAsync(async context => { - _context.BatchOperationHistory.RemoveRange(toDelete); - await _context.SaveChangesAsync(); - - _logger.LogInformation( - "Deleted {Count} batch operation history records older than {Date}", - toDelete.Count(), olderThan); - } - - return toDelete.Count(); + var toDelete = await GetDbSet(context) + .Where(h => h.StartedAt < olderThan) + .ToListAsync(); + + if (toDelete.Any()) + { + GetDbSet(context).RemoveRange(toDelete); + await context.SaveChangesAsync(); + + Logger.LogInformation( + "Deleted {Count} batch operation history records older than {Date}", + toDelete.Count, olderThan); + } + + return toDelete.Count; + }, operationName: "deleting old history"); } + /// public async Task GetStatisticsAsync(int virtualKeyId, DateTime? since = null) { - var query = _context.BatchOperationHistory - .Where(h => h.VirtualKeyId == virtualKeyId); - - if (since.HasValue) + return await ExecuteAsync(async context => { - query = query.Where(h => h.StartedAt >= since.Value); - } + var query = GetDbSet(context) + .Where(h => h.VirtualKeyId == virtualKeyId); - var operations = await query.ToListAsync(); - - if (operations.Count() == 0) - { - return new BatchOperationStatistics(); - } + if (since.HasValue) + { + query = query.Where(h => h.StartedAt >= since.Value); + } - var stats = new BatchOperationStatistics - { - TotalOperations = operations.Count(), - SuccessfulOperations = operations.Count(h => h.Status == "Completed"), - FailedOperations = operations.Count(h => h.Status == "Failed"), - CancelledOperations = operations.Count(h => h.Status == "Cancelled"), - TotalItemsProcessed = operations.Sum(h => h.SuccessCount + h.FailedCount), - TotalItemsSucceeded = operations.Sum(h => h.SuccessCount), - TotalItemsFailed = operations.Sum(h => h.FailedCount) - }; - - // Calculate averages only for completed operations - var completedOps = operations.Where(h => h.DurationSeconds.HasValue && h.ItemsPerSecond.HasValue).ToList(); - if (completedOps.Count() > 0) - { - stats.AverageDurationSeconds = completedOps.Average(h => h.DurationSeconds!.Value); - stats.AverageItemsPerSecond = completedOps.Average(h => h.ItemsPerSecond!.Value); - } + var operations = await query.ToListAsync(); + + if (!operations.Any()) + { + return new BatchOperationStatistics(); + } + + var stats = new BatchOperationStatistics + { + TotalOperations = operations.Count, + SuccessfulOperations = operations.Count(h => h.Status == "Completed"), + FailedOperations = operations.Count(h => h.Status == "Failed"), + CancelledOperations = operations.Count(h => h.Status == "Cancelled"), + TotalItemsProcessed = operations.Sum(h => h.SuccessCount + h.FailedCount), + TotalItemsSucceeded = operations.Sum(h => h.SuccessCount), + TotalItemsFailed = operations.Sum(h => h.FailedCount) + }; + + // Calculate averages only for completed operations + var completedOps = operations.Where(h => h.DurationSeconds.HasValue && h.ItemsPerSecond.HasValue).ToList(); + if (completedOps.Any()) + { + stats.AverageDurationSeconds = completedOps.Average(h => h.DurationSeconds!.Value); + stats.AverageItemsPerSecond = completedOps.Average(h => h.ItemsPerSecond!.Value); + } - // Count by operation type - stats.OperationTypeCounts = operations - .GroupBy(h => h.OperationType) - .ToDictionary(g => g.Key, g => g.Count()); + // Count by operation type + stats.OperationTypeCounts = operations + .GroupBy(h => h.OperationType) + .ToDictionary(g => g.Key, g => g.Count()); - return stats; + return stats; + }, operationName: "getting statistics"); } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/FunctionConfigurationRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/FunctionConfigurationRepository.cs index ecaf8de83..3adf12098 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/FunctionConfigurationRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/FunctionConfigurationRepository.cs @@ -1,4 +1,3 @@ -using ConduitLLM.Configuration; using ConduitLLM.Configuration.Utilities; using ConduitLLM.Functions.Entities; using ConduitLLM.Functions.Enums; @@ -9,38 +8,23 @@ namespace ConduitLLM.Configuration.Repositories; /// -/// Repository implementation for function configurations using Entity Framework Core. +/// Repository implementation for function configurations using RepositoryBase. /// -public class FunctionConfigurationRepository : IFunctionConfigurationRepository +public class FunctionConfigurationRepository : RepositoryBase, IFunctionConfigurationRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - public FunctionConfigurationRepository( IDbContextFactory dbContextFactory, ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + : base(dbContextFactory, logger) { } - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionConfigurations - .AsNoTracking() - .Include(f => f.CostMappings) - .ThenInclude(cm => cm.FunctionCost) - .FirstOrDefaultAsync(f => f.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function configuration with ID {ConfigId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } + protected override DbSet GetDbSet(ConduitDbContext context) + => context.FunctionConfigurations; + + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + => query.Include(f => f.CostMappings).ThenInclude(cm => cm.FunctionCost); + + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + => query.OrderBy(f => f.ConfigurationName); public async Task> GetByIdsAsync(List ids, CancellationToken cancellationToken = default) { @@ -49,21 +33,12 @@ public async Task> GetByIdsAsync(List ids, Canc return new List(); } - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionConfigurations - .AsNoTracking() - .Include(f => f.CostMappings) - .ThenInclude(cm => cm.FunctionCost) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .Where(f => ids.Contains(f.Id)) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function configurations with IDs {ConfigIds}", LogSanitizer.SanitizeObject(ids)); - throw; - } + }, cancellationToken, "GetByIds"); } public async Task GetByNameAsync(string configurationName, CancellationToken cancellationToken = default) @@ -73,251 +48,77 @@ public async Task> GetByIdsAsync(List ids, Canc throw new ArgumentException("Configuration name cannot be null or empty", nameof(configurationName)); } - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionConfigurations - .AsNoTracking() - .Include(f => f.CostMappings) - .ThenInclude(cm => cm.FunctionCost) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .FirstOrDefaultAsync(f => f.ConfigurationName == configurationName, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function configuration with name {ConfigName}", - LogSanitizer.SanitizeObject(configurationName)); - throw; - } - } - - public async Task> GetAllAsync(CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionConfigurations - .AsNoTracking() - .Include(f => f.CostMappings) - .ThenInclude(cm => cm.FunctionCost) - .OrderBy(f => f.ConfigurationName) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all function configurations"); - throw; - } + }, cancellationToken, "GetByName"); } public async Task> GetAllEnabledAsync(CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionConfigurations - .AsNoTracking() - .Include(f => f.CostMappings) - .ThenInclude(cm => cm.FunctionCost) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .Where(f => f.IsEnabled) .OrderBy(f => f.ConfigurationName) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all enabled function configurations"); - throw; - } + }, cancellationToken, "GetAllEnabled"); } public async Task> GetByProviderTypeAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionConfigurations - .AsNoTracking() - .Include(f => f.CostMappings) - .ThenInclude(cm => cm.FunctionCost) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .Where(f => f.ProviderType == providerType) .OrderBy(f => f.ConfigurationName) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function configurations for provider type {ProviderType}", - LogSanitizer.SanitizeObject(providerType)); - throw; - } + }, cancellationToken, "GetByProviderType"); } public async Task> GetByPurposeAsync(FunctionPurpose purpose, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionConfigurations - .AsNoTracking() - .Include(f => f.CostMappings) - .ThenInclude(cm => cm.FunctionCost) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .Where(f => f.Purpose == purpose) .OrderBy(f => f.ConfigurationName) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function configurations for purpose {Purpose}", - LogSanitizer.SanitizeObject(purpose)); - throw; - } - } - - public async Task CreateAsync(FunctionConfiguration functionConfiguration, CancellationToken cancellationToken = default) - { - if (functionConfiguration == null) - { - throw new ArgumentNullException(nameof(functionConfiguration)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - functionConfiguration.CreatedAt = DateTime.UtcNow; - functionConfiguration.UpdatedAt = DateTime.UtcNow; - - dbContext.FunctionConfigurations.Add(functionConfiguration); - await dbContext.SaveChangesAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - - return functionConfiguration.Id; - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while creating function configuration '{ConfigName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(functionConfiguration.ConfigurationName))); - throw; - } - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating function configuration '{ConfigName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(functionConfiguration.ConfigurationName))); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating function configuration '{ConfigName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(functionConfiguration.ConfigurationName))); - throw; - } + }, cancellationToken, "GetByPurpose"); } - public async Task UpdateAsync(FunctionConfiguration functionConfiguration, CancellationToken cancellationToken = default) + /// + /// Overrides base UpdateAsync to add concurrency retry logic. + /// + public override async Task UpdateAsync(FunctionConfiguration entity, CancellationToken cancellationToken = default) { - if (functionConfiguration == null) - { - throw new ArgumentNullException(nameof(functionConfiguration)); - } + ArgumentNullException.ThrowIfNull(entity); try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - functionConfiguration.UpdatedAt = DateTime.UtcNow; - - dbContext.FunctionConfigurations.Update(functionConfiguration); - await dbContext.SaveChangesAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Concurrency error updating function configuration with ID {ConfigId}", - LogSanitizer.SanitizeObject(functionConfiguration.Id)); - - // Retry logic - try - { - using var retryDbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var retryTransaction = await retryDbContext.Database.BeginTransactionAsync(cancellationToken); - - var existingEntity = await retryDbContext.FunctionConfigurations - .FindAsync(new object[] { functionConfiguration.Id }, cancellationToken); - - if (existingEntity != null) - { - retryDbContext.Entry(existingEntity).CurrentValues.SetValues(functionConfiguration); - existingEntity.UpdatedAt = DateTime.UtcNow; - - await retryDbContext.SaveChangesAsync(cancellationToken); - await retryTransaction.CommitAsync(cancellationToken); - } - } - catch (Exception retryEx) - { - _logger.LogError(retryEx, "Error during retry of function configuration update with ID {ConfigId}", - LogSanitizer.SanitizeObject(functionConfiguration.Id)); - throw; - } - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while updating function configuration with ID {ConfigId}", - LogSanitizer.SanitizeObject(functionConfiguration.Id)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating function configuration with ID {ConfigId}", - LogSanitizer.SanitizeObject(functionConfiguration.Id)); - throw; + return await base.UpdateAsync(entity, cancellationToken); } - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try + catch (DbUpdateConcurrencyException ex) { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + Logger.LogError(ex, "Concurrency error updating function configuration with ID {ConfigId}", + LoggingSanitizer.S(entity.Id)); - try + // Retry with fresh context + return await ExecuteAsync(async db => { - var functionConfiguration = await dbContext.FunctionConfigurations - .FindAsync(new object[] { id }, cancellationToken); + var existingEntity = await GetDbSet(db) + .FindAsync(new object[] { entity.Id }, cancellationToken); - if (functionConfiguration != null) + if (existingEntity != null) { - dbContext.FunctionConfigurations.Remove(functionConfiguration); - await dbContext.SaveChangesAsync(cancellationToken); + db.Entry(existingEntity).CurrentValues.SetValues(entity); + existingEntity.UpdatedAt = DateTime.UtcNow; + return await db.SaveChangesAsync(cancellationToken) > 0; } - await transaction.CommitAsync(cancellationToken); - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while deleting function configuration with ID {ConfigId}", - LogSanitizer.SanitizeObject(id)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting function configuration with ID {ConfigId}", - LogSanitizer.SanitizeObject(id)); - throw; + return false; + }, cancellationToken, "UpdateAsync-Retry"); } } @@ -328,12 +129,9 @@ public async Task NameExistsAsync(string configurationName, int? excludeId throw new ArgumentException("Configuration name cannot be null or empty", nameof(configurationName)); } - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var query = dbContext.FunctionConfigurations - .AsNoTracking() + var query = GetDbSet(db).AsNoTracking() .Where(f => f.ConfigurationName == configurationName); if (excludeId.HasValue) @@ -342,12 +140,6 @@ public async Task NameExistsAsync(string configurationName, int? excludeId } return await query.AnyAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking if function configuration name exists: {ConfigName}", - LogSanitizer.SanitizeObject(configurationName)); - throw; - } + }, cancellationToken, "NameExists"); } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/FunctionCostMappingRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/FunctionCostMappingRepository.cs index ae856152e..a39ce7cf0 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/FunctionCostMappingRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/FunctionCostMappingRepository.cs @@ -1,4 +1,3 @@ -using ConduitLLM.Configuration; using ConduitLLM.Configuration.Utilities; using ConduitLLM.Functions.Entities; using ConduitLLM.Functions.Interfaces; @@ -8,227 +7,63 @@ namespace ConduitLLM.Configuration.Repositories; /// -/// Repository implementation for function cost mappings using Entity Framework Core. +/// Repository implementation for function cost mappings using RepositoryBase. /// -public class FunctionCostMappingRepository : IFunctionCostMappingRepository +public class FunctionCostMappingRepository : RepositoryBase, IFunctionCostMappingRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - public FunctionCostMappingRepository( IDbContextFactory dbContextFactory, ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + : base(dbContextFactory, logger) { } - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCostMappings - .AsNoTracking() - .Include(m => m.FunctionConfiguration) - .Include(m => m.FunctionCost) - .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function cost mapping with ID {MappingId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } + protected override DbSet GetDbSet(ConduitDbContext context) + => context.FunctionCostMappings; - public async Task> GetByFunctionConfigurationIdAsync(int functionConfigurationId, CancellationToken cancellationToken = default) + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + => query.Include(m => m.FunctionConfiguration).Include(m => m.FunctionCost); + + public async Task> GetByFunctionConfigurationIdAsync( + int functionConfigurationId, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCostMappings - .AsNoTracking() - .Include(m => m.FunctionCost) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .Where(m => m.FunctionConfigurationId == functionConfigurationId) .OrderByDescending(m => m.IsActive) .ThenByDescending(m => m.FunctionCost!.Priority) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting cost mappings for function configuration {ConfigId}", - LogSanitizer.SanitizeObject(functionConfigurationId)); - throw; - } + }, cancellationToken, "GetByFunctionConfigurationId"); } - public async Task GetActiveMappingAsync(int functionConfigurationId, CancellationToken cancellationToken = default) + public async Task GetActiveMappingAsync( + int functionConfigurationId, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCostMappings - .AsNoTracking() + return await GetDbSet(db).AsNoTracking() .Include(m => m.FunctionCost) .Where(m => m.FunctionConfigurationId == functionConfigurationId && m.IsActive) .OrderByDescending(m => m.FunctionCost!.Priority) .FirstOrDefaultAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting active mapping for function configuration {ConfigId}", - LogSanitizer.SanitizeObject(functionConfigurationId)); - throw; - } - } - - public async Task CreateAsync(FunctionCostMapping mapping, CancellationToken cancellationToken = default) - { - if (mapping == null) - { - throw new ArgumentNullException(nameof(mapping)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - mapping.CreatedAt = DateTime.UtcNow; - - dbContext.FunctionCostMappings.Add(mapping); - await dbContext.SaveChangesAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - - return mapping.Id; - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while creating function cost mapping"); - throw; - } - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating function cost mapping"); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating function cost mapping"); - throw; - } + }, cancellationToken, "GetActiveMapping"); } - public async Task UpdateAsync(FunctionCostMapping mapping, CancellationToken cancellationToken = default) + public async Task DeactivateAllForFunctionAsync( + int functionConfigurationId, CancellationToken cancellationToken = default) { - if (mapping == null) - { - throw new ArgumentNullException(nameof(mapping)); - } - - try + await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - dbContext.FunctionCostMappings.Update(mapping); - await dbContext.SaveChangesAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while updating function cost mapping with ID {MappingId}", - LogSanitizer.SanitizeObject(mapping.Id)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating function cost mapping with ID {MappingId}", - LogSanitizer.SanitizeObject(mapping.Id)); - throw; - } - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - var mapping = await dbContext.FunctionCostMappings - .FindAsync(new object[] { id }, cancellationToken); - - if (mapping != null) - { - dbContext.FunctionCostMappings.Remove(mapping); - await dbContext.SaveChangesAsync(cancellationToken); - } + var mappings = await GetDbSet(db) + .Where(m => m.FunctionConfigurationId == functionConfigurationId && m.IsActive) + .ToListAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - } - catch (Exception ex) + foreach (var mapping in mappings) { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while deleting function cost mapping with ID {MappingId}", - LogSanitizer.SanitizeObject(id)); - throw; + mapping.IsActive = false; } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting function cost mapping with ID {MappingId}", - LogSanitizer.SanitizeObject(id)); - throw; - } - } - - public async Task DeactivateAllForFunctionAsync(int functionConfigurationId, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - try - { - var mappings = await dbContext.FunctionCostMappings - .Where(m => m.FunctionConfigurationId == functionConfigurationId && m.IsActive) - .ToListAsync(cancellationToken); - - foreach (var mapping in mappings) - { - mapping.IsActive = false; - } - - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while deactivating mappings for function {ConfigId}", - LogSanitizer.SanitizeObject(functionConfigurationId)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deactivating mappings for function configuration {ConfigId}", - LogSanitizer.SanitizeObject(functionConfigurationId)); - throw; - } + await db.SaveChangesAsync(cancellationToken); + return true; + }, cancellationToken, "DeactivateAllForFunction"); } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/FunctionCostRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/FunctionCostRepository.cs index 528e0394f..ac340f0ea 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/FunctionCostRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/FunctionCostRepository.cs @@ -1,5 +1,3 @@ -using ConduitLLM.Configuration; -using ConduitLLM.Configuration.Utilities; using ConduitLLM.Functions.Entities; using ConduitLLM.Functions.Interfaces; using Microsoft.EntityFrameworkCore; @@ -8,37 +6,23 @@ namespace ConduitLLM.Configuration.Repositories; /// -/// Repository implementation for function costs using Entity Framework Core. +/// Repository implementation for function costs using RepositoryBase. /// -public class FunctionCostRepository : IFunctionCostRepository +public class FunctionCostRepository : RepositoryBase, IFunctionCostRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - public FunctionCostRepository( IDbContextFactory dbContextFactory, ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + : base(dbContextFactory, logger) { } - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCosts - .AsNoTracking() - .Include(c => c.FunctionMappings) - .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function cost with ID {CostId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } + protected override DbSet GetDbSet(ConduitDbContext context) + => context.FunctionCosts; + + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + => query.Include(c => c.FunctionMappings); + + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + => query.OrderBy(c => c.CostName); public async Task GetByCostNameAsync(string costName, CancellationToken cancellationToken = default) { @@ -47,72 +31,34 @@ public FunctionCostRepository( throw new ArgumentException("Cost name cannot be null or empty", nameof(costName)); } - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCosts - .AsNoTracking() - .Include(c => c.FunctionMappings) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .FirstOrDefaultAsync(c => c.CostName == costName, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function cost with name {CostName}", - LogSanitizer.SanitizeObject(costName)); - throw; - } - } - - public async Task> GetAllAsync(CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCosts - .AsNoTracking() - .Include(c => c.FunctionMappings) - .OrderBy(c => c.CostName) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all function costs"); - throw; - } + }, cancellationToken, "GetByCostName"); } public async Task> GetAllActiveAsync(CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); var now = DateTime.UtcNow; - - return await dbContext.FunctionCosts - .AsNoTracking() - .Include(c => c.FunctionMappings) + return await ApplyDefaultIncludes(GetDbSet(db).AsNoTracking()) .Where(c => c.IsActive && c.EffectiveDate <= now && (c.ExpiryDate == null || c.ExpiryDate > now)) .OrderBy(c => c.CostName) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all active function costs"); - throw; - } + }, cancellationToken, "GetAllActive"); } public async Task GetActiveCostForFunctionAsync(int functionConfigurationId, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); var now = DateTime.UtcNow; - // Get the active mapping for this function - var mapping = await dbContext.FunctionCostMappings + var mapping = await db.FunctionCostMappings .AsNoTracking() .Include(m => m.FunctionCost) .Where(m => m.FunctionConfigurationId == functionConfigurationId && m.IsActive) @@ -125,7 +71,6 @@ public async Task> GetAllActiveAsync(CancellationToken cancel return null; } - // Verify the cost is currently active and effective if (mapping.FunctionCost.IsActive && mapping.FunctionCost.EffectiveDate <= now && (mapping.FunctionCost.ExpiryDate == null || mapping.FunctionCost.ExpiryDate > now)) @@ -134,131 +79,6 @@ public async Task> GetAllActiveAsync(CancellationToken cancel } return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting active cost for function configuration {ConfigId}", - LogSanitizer.SanitizeObject(functionConfigurationId)); - throw; - } - } - - public async Task CreateAsync(FunctionCost functionCost, CancellationToken cancellationToken = default) - { - if (functionCost == null) - { - throw new ArgumentNullException(nameof(functionCost)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - functionCost.CreatedAt = DateTime.UtcNow; - functionCost.UpdatedAt = DateTime.UtcNow; - - dbContext.FunctionCosts.Add(functionCost); - await dbContext.SaveChangesAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - - return functionCost.Id; - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while creating function cost '{CostName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(functionCost.CostName))); - throw; - } - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating function cost '{CostName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(functionCost.CostName))); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating function cost '{CostName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(functionCost.CostName))); - throw; - } - } - - public async Task UpdateAsync(FunctionCost functionCost, CancellationToken cancellationToken = default) - { - if (functionCost == null) - { - throw new ArgumentNullException(nameof(functionCost)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - functionCost.UpdatedAt = DateTime.UtcNow; - - dbContext.FunctionCosts.Update(functionCost); - await dbContext.SaveChangesAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while updating function cost with ID {CostId}", - LogSanitizer.SanitizeObject(functionCost.Id)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating function cost with ID {CostId}", - LogSanitizer.SanitizeObject(functionCost.Id)); - throw; - } - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - var functionCost = await dbContext.FunctionCosts - .FindAsync(new object[] { id }, cancellationToken); - - if (functionCost != null) - { - dbContext.FunctionCosts.Remove(functionCost); - await dbContext.SaveChangesAsync(cancellationToken); - } - - await transaction.CommitAsync(cancellationToken); - } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while deleting function cost with ID {CostId}", - LogSanitizer.SanitizeObject(id)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting function cost with ID {CostId}", - LogSanitizer.SanitizeObject(id)); - throw; - } + }, cancellationToken, "GetActiveCostForFunction"); } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/FunctionCredentialRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/FunctionCredentialRepository.cs index c4856f77f..cfb4e9c13 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/FunctionCredentialRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/FunctionCredentialRepository.cs @@ -1,4 +1,3 @@ -using ConduitLLM.Configuration; using ConduitLLM.Configuration.Utilities; using ConduitLLM.Functions.Entities; using ConduitLLM.Functions.Enums; @@ -9,348 +8,169 @@ namespace ConduitLLM.Configuration.Repositories; /// -/// Repository implementation for function credentials using Entity Framework Core. +/// Repository implementation for function credentials using RepositoryBase. +/// Overrides Create/Update to implement auto-primary credential logic. /// -public class FunctionCredentialRepository : IFunctionCredentialRepository +public class FunctionCredentialRepository : RepositoryBase, IFunctionCredentialRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - public FunctionCredentialRepository( IDbContextFactory dbContextFactory, ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + : base(dbContextFactory, logger) { } - public async Task> GetAllAsync(CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCredentials - .AsNoTracking() - .OrderBy(c => c.ProviderType) - .ThenByDescending(c => c.IsPrimary) - .ThenBy(c => c.KeyName) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all function credentials"); - throw; - } - } + protected override DbSet GetDbSet(ConduitDbContext context) + => context.FunctionCredentials; - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCredentials - .AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting function credential with ID {CredentialId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + => query.OrderBy(c => c.ProviderType).ThenByDescending(c => c.IsPrimary).ThenBy(c => c.KeyName); public async Task> GetByProviderTypeAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCredentials - .AsNoTracking() + return await GetDbSet(db).AsNoTracking() .Where(c => c.ProviderType == providerType) .OrderByDescending(c => c.IsPrimary) .ThenBy(c => c.KeyName) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting credentials for provider type {ProviderType}", - LogSanitizer.SanitizeObject(providerType)); - throw; - } + }, cancellationToken, "GetByProviderType"); } public async Task> GetEnabledByProviderTypeAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCredentials - .AsNoTracking() + return await GetDbSet(db).AsNoTracking() .Where(c => c.ProviderType == providerType && c.IsEnabled) .OrderByDescending(c => c.IsPrimary) .ThenBy(c => c.KeyName) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting enabled credentials for provider type {ProviderType}", - LogSanitizer.SanitizeObject(providerType)); - throw; - } + }, cancellationToken, "GetEnabledByProviderType"); } public async Task GetPrimaryCredentialAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCredentials - .AsNoTracking() + return await GetDbSet(db).AsNoTracking() .Where(c => c.ProviderType == providerType && c.IsPrimary && c.IsEnabled) .FirstOrDefaultAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting primary credential for provider type {ProviderType}", - LogSanitizer.SanitizeObject(providerType)); - throw; - } + }, cancellationToken, "GetPrimaryCredential"); } public async Task> GetByCredentialGroupAsync(FunctionProviderType providerType, short functionAccountGroup, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionCredentials - .AsNoTracking() + return await GetDbSet(db).AsNoTracking() .Where(c => c.ProviderType == providerType && c.FunctionAccountGroup == functionAccountGroup) .OrderByDescending(c => c.IsPrimary) .ThenBy(c => c.KeyName) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting credentials for group {Group} in provider type {ProviderType}", - LogSanitizer.SanitizeObject(functionAccountGroup), LogSanitizer.SanitizeObject(providerType)); - throw; - } + }, cancellationToken, "GetByCredentialGroup"); } - public async Task CreateAsync(FunctionCredential credential, CancellationToken cancellationToken = default) + /// + /// Overrides base CreateAsync to implement auto-primary credential logic. + /// If this is the first enabled credential for a provider type, it's automatically set as primary. + /// If this credential is primary, existing primary credentials are unset. + /// + public override async Task CreateAsync(FunctionCredential credential, CancellationToken cancellationToken = default) { - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } + ArgumentNullException.ThrowIfNull(credential); - try + return await ExecuteAsync(async db => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + credential.CreatedAt = DateTime.UtcNow; + credential.UpdatedAt = DateTime.UtcNow; - try + // Auto-primary: If first enabled credential, set as primary + if (credential.IsEnabled && !credential.IsPrimary) { - credential.CreatedAt = DateTime.UtcNow; - credential.UpdatedAt = DateTime.UtcNow; + var enabledCount = await GetDbSet(db) + .CountAsync(c => c.ProviderType == credential.ProviderType && c.IsEnabled, cancellationToken); - // Auto-primary logic: If this is the first enabled credential, automatically set it as primary - // This mirrors the ProviderKeyCredentialRepository pattern - if (credential.IsEnabled && !credential.IsPrimary) + if (enabledCount == 0) { - var enabledCredentialsCount = await dbContext.FunctionCredentials - .CountAsync(c => c.ProviderType == credential.ProviderType && c.IsEnabled, cancellationToken); - - // If this will be the only enabled credential, set it as primary - if (enabledCredentialsCount == 0) - { - credential.IsPrimary = true; - _logger.LogInformation("Automatically setting credential as primary since it's the only enabled credential for provider type {ProviderType}", - LogSanitizer.SanitizeObject(credential.ProviderType)); - } + credential.IsPrimary = true; + Logger.LogInformation("Automatically setting credential as primary since it's the only enabled credential for provider type {ProviderType}", + LoggingSanitizer.S(credential.ProviderType)); } - - // If this credential is being set as primary, unset any existing primary - if (credential.IsPrimary) - { - var existingPrimary = await dbContext.FunctionCredentials - .Where(c => c.ProviderType == credential.ProviderType && c.IsPrimary) - .ToListAsync(cancellationToken); - - foreach (var existing in existingPrimary) - { - existing.IsPrimary = false; - existing.UpdatedAt = DateTime.UtcNow; - } - } - - dbContext.FunctionCredentials.Add(credential); - await dbContext.SaveChangesAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - - return credential.Id; } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while creating function credential '{KeyName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(credential.KeyName))); - throw; - } - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating function credential '{KeyName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(credential.KeyName))); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating function credential '{KeyName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(credential.KeyName))); - throw; - } - } - public async Task UpdateAsync(FunctionCredential credential, CancellationToken cancellationToken = default) - { - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try + // If setting as primary, unset existing primary + if (credential.IsPrimary) { - var existingCredential = await dbContext.FunctionCredentials - .FirstOrDefaultAsync(c => c.Id == credential.Id, cancellationToken); - - if (existingCredential == null) - { - throw new InvalidOperationException($"Credential {credential.Id} not found"); - } - - bool wasEnabled = existingCredential.IsEnabled; - bool willBeEnabled = credential.IsEnabled; - - // Update the existing tracked entity with new values - existingCredential.KeyName = credential.KeyName; - existingCredential.ApiKey = credential.ApiKey; - existingCredential.BaseUrl = credential.BaseUrl; - existingCredential.Organization = credential.Organization; - existingCredential.FunctionAccountGroup = credential.FunctionAccountGroup; - existingCredential.IsPrimary = credential.IsPrimary; - existingCredential.IsEnabled = credential.IsEnabled; - existingCredential.UpdatedAt = DateTime.UtcNow; + var existingPrimary = await GetDbSet(db) + .Where(c => c.ProviderType == credential.ProviderType && c.IsPrimary) + .ToListAsync(cancellationToken); - // Auto-primary logic: If being enabled and this will be the only enabled credential, set it as primary - // This mirrors the ProviderKeyCredentialRepository pattern - if (!wasEnabled && willBeEnabled && !existingCredential.IsPrimary) + foreach (var existing in existingPrimary) { - var enabledCredentialsCount = await dbContext.FunctionCredentials - .CountAsync(c => c.ProviderType == existingCredential.ProviderType - && c.IsEnabled - && c.Id != existingCredential.Id, cancellationToken); - - // If this will be the only enabled credential, set it as primary - if (enabledCredentialsCount == 0) - { - existingCredential.IsPrimary = true; - _logger.LogInformation("Automatically setting credential {CredentialId} as primary since it's the only enabled credential for provider type {ProviderType}", - LogSanitizer.SanitizeObject(existingCredential.Id), LogSanitizer.SanitizeObject(existingCredential.ProviderType)); - } + existing.IsPrimary = false; + existing.UpdatedAt = DateTime.UtcNow; } + } - // If this credential is being set as primary, unset any existing primary - if (existingCredential.IsPrimary) - { - var existingPrimary = await dbContext.FunctionCredentials - .Where(c => c.ProviderType == existingCredential.ProviderType - && c.IsPrimary - && c.Id != existingCredential.Id) - .ToListAsync(cancellationToken); + GetDbSet(db).Add(credential); + await db.SaveChangesAsync(cancellationToken); + return credential.Id; + }, cancellationToken, "CreateAsync"); + } - foreach (var existing in existingPrimary) - { - existing.IsPrimary = false; - existing.UpdatedAt = DateTime.UtcNow; - } - } + /// + /// Overrides base UpdateAsync to implement auto-primary credential logic. + /// + public override async Task UpdateAsync(FunctionCredential credential, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(credential); - // No need to call Update() since we're modifying a tracked entity - await dbContext.SaveChangesAsync(cancellationToken); + return await ExecuteAsync(async db => + { + var existingCredential = await GetDbSet(db) + .FirstOrDefaultAsync(c => c.Id == credential.Id, cancellationToken); - await transaction.CommitAsync(cancellationToken); - } - catch (Exception ex) + if (existingCredential == null) { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while updating function credential with ID {CredentialId}", - LogSanitizer.SanitizeObject(credential.Id)); - throw; + throw new InvalidOperationException($"Credential {credential.Id} not found"); } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating function credential with ID {CredentialId}", - LogSanitizer.SanitizeObject(credential.Id)); - throw; - } - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - try + bool wasEnabled = existingCredential.IsEnabled; + bool willBeEnabled = credential.IsEnabled; + + // Update the existing tracked entity with new values + existingCredential.KeyName = credential.KeyName; + existingCredential.ApiKey = credential.ApiKey; + existingCredential.BaseUrl = credential.BaseUrl; + existingCredential.Organization = credential.Organization; + existingCredential.FunctionAccountGroup = credential.FunctionAccountGroup; + existingCredential.IsPrimary = credential.IsPrimary; + existingCredential.IsEnabled = credential.IsEnabled; + existingCredential.UpdatedAt = DateTime.UtcNow; + + // Auto-primary: If being enabled and will be the only enabled credential + if (!wasEnabled && willBeEnabled && !existingCredential.IsPrimary) { - var credential = await dbContext.FunctionCredentials - .FindAsync(new object[] { id }, cancellationToken); + var enabledCount = await GetDbSet(db) + .CountAsync(c => c.ProviderType == existingCredential.ProviderType + && c.IsEnabled + && c.Id != existingCredential.Id, cancellationToken); - if (credential != null) + if (enabledCount == 0) { - dbContext.FunctionCredentials.Remove(credential); - await dbContext.SaveChangesAsync(cancellationToken); + existingCredential.IsPrimary = true; + Logger.LogInformation("Automatically setting credential {CredentialId} as primary since it's the only enabled credential for provider type {ProviderType}", + LoggingSanitizer.S(existingCredential.Id), LoggingSanitizer.S(existingCredential.ProviderType)); } - - await transaction.CommitAsync(cancellationToken); } - catch (Exception ex) - { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while deleting function credential with ID {CredentialId}", - LogSanitizer.SanitizeObject(id)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting function credential with ID {CredentialId}", - LogSanitizer.SanitizeObject(id)); - throw; - } - } - - public async Task SetAsPrimaryAsync(int credentialId, FunctionProviderType providerType, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - try + // If setting as primary, unset existing primary + if (existingCredential.IsPrimary) { - // Unset all existing primary credentials for this provider type - var existingPrimary = await dbContext.FunctionCredentials - .Where(c => c.ProviderType == providerType && c.IsPrimary) + var existingPrimary = await GetDbSet(db) + .Where(c => c.ProviderType == existingCredential.ProviderType + && c.IsPrimary + && c.Id != existingCredential.Id) .ToListAsync(cancellationToken); foreach (var existing in existingPrimary) @@ -358,36 +178,42 @@ public async Task SetAsPrimaryAsync(int credentialId, FunctionProviderType provi existing.IsPrimary = false; existing.UpdatedAt = DateTime.UtcNow; } + } - // Set the specified credential as primary - var credential = await dbContext.FunctionCredentials - .FirstOrDefaultAsync(c => c.Id == credentialId && c.ProviderType == providerType, - cancellationToken); - - if (credential == null) - { - throw new InvalidOperationException($"Credential {credentialId} not found for provider type {providerType}"); - } + return await db.SaveChangesAsync(cancellationToken) > 0; + }, cancellationToken, "UpdateAsync"); + } - credential.IsPrimary = true; - credential.UpdatedAt = DateTime.UtcNow; + public async Task SetAsPrimaryAsync(int credentialId, FunctionProviderType providerType, CancellationToken cancellationToken = default) + { + await ExecuteAsync(async db => + { + // Unset all existing primary credentials for this provider type + var existingPrimary = await GetDbSet(db) + .Where(c => c.ProviderType == providerType && c.IsPrimary) + .ToListAsync(cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + foreach (var existing in existingPrimary) + { + existing.IsPrimary = false; + existing.UpdatedAt = DateTime.UtcNow; } - catch (Exception ex) + + // Set the specified credential as primary + var credential = await GetDbSet(db) + .FirstOrDefaultAsync(c => c.Id == credentialId && c.ProviderType == providerType, + cancellationToken); + + if (credential == null) { - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while setting credential {CredentialId} as primary", - LogSanitizer.SanitizeObject(credentialId)); - throw; + throw new InvalidOperationException($"Credential {credentialId} not found for provider type {providerType}"); } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error setting credential {CredentialId} as primary", - LogSanitizer.SanitizeObject(credentialId)); - throw; - } + + credential.IsPrimary = true; + credential.UpdatedAt = DateTime.UtcNow; + + await db.SaveChangesAsync(cancellationToken); + return true; + }, cancellationToken, "SetAsPrimary"); } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/FunctionExecutionRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/FunctionExecutionRepository.cs index 15b1bacd0..e0aa770e4 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/FunctionExecutionRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/FunctionExecutionRepository.cs @@ -1,4 +1,3 @@ -using ConduitLLM.Configuration; using ConduitLLM.Configuration.Utilities; using ConduitLLM.Functions.Entities; using ConduitLLM.Functions.Enums; @@ -10,99 +9,158 @@ namespace ConduitLLM.Configuration.Repositories; /// /// Repository implementation for function executions using Entity Framework Core. +/// Extends RepositoryBase for standard CRUD operations and adds domain-specific methods. /// Includes distributed execution support via leasing mechanism. /// -public class FunctionExecutionRepository : IFunctionExecutionRepository +public class FunctionExecutionRepository : RepositoryBase, IFunctionExecutionRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - + /// + /// Creates a new instance of the repository. + /// + /// The database context factory + /// The logger instance public FunctionExecutionRepository( IDbContextFactory dbContextFactory, ILogger logger) + : base(dbContextFactory, logger) + { + } + + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.FunctionExecutions; + + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + return query.Include(e => e.FunctionConfiguration); } - public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderByDescending(e => e.RequestedAt); + } + + #region Query Methods + + /// + public async Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default) { try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionExecutions - .AsNoTracking() - .Include(e => e.FunctionConfiguration) - - .FirstOrDefaultAsync(e => e.Id == id, cancellationToken); + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Include(e => e.FunctionConfiguration) + .Where(e => e.VirtualKeyId == virtualKeyId) + .OrderByDescending(e => e.RequestedAt) + .ToListAsync(cancellationToken), + cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error getting function execution with ID {ExecutionId}", LogSanitizer.SanitizeObject(id)); + Logger.LogError(ex, "Error getting executions for virtual key {VirtualKeyId}", + LoggingSanitizer.S(virtualKeyId)); throw; } } - public async Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default) + /// + public async Task> GetByFunctionConfigurationIdAsync(int functionConfigurationId, CancellationToken cancellationToken = default) { try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionExecutions - .AsNoTracking() - .Include(e => e.FunctionConfiguration) - .Where(e => e.VirtualKeyId == virtualKeyId) - .OrderByDescending(e => e.RequestedAt) - .ToListAsync(cancellationToken); + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Where(e => e.FunctionConfigurationId == functionConfigurationId) + .OrderByDescending(e => e.RequestedAt) + .ToListAsync(cancellationToken), + cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error getting executions for virtual key {VirtualKeyId}", - LogSanitizer.SanitizeObject(virtualKeyId)); + Logger.LogError(ex, "Error getting executions for function configuration {ConfigId}", + LoggingSanitizer.S(functionConfigurationId)); throw; } } - public async Task> GetByFunctionConfigurationIdAsync(int functionConfigurationId, CancellationToken cancellationToken = default) + /// + public async Task> GetByStateAsync(ExecutionState state, CancellationToken cancellationToken = default) { try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionExecutions - .AsNoTracking() - - .Where(e => e.FunctionConfigurationId == functionConfigurationId) - .OrderByDescending(e => e.RequestedAt) - .ToListAsync(cancellationToken); + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Include(e => e.FunctionConfiguration) + .Where(e => e.State == state) + .OrderBy(e => e.RequestedAt) + .ToListAsync(cancellationToken), + cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error getting executions for function configuration {ConfigId}", - LogSanitizer.SanitizeObject(functionConfigurationId)); + Logger.LogError(ex, "Error getting executions with state {State}", LoggingSanitizer.S(state)); throw; } } - public async Task> GetByStateAsync(ExecutionState state, CancellationToken cancellationToken = default) + /// + public async Task> GetExpiredLeasesAsync(CancellationToken cancellationToken = default) { try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.FunctionExecutions - .AsNoTracking() - .Include(e => e.FunctionConfiguration) - - .Where(e => e.State == state) - .OrderBy(e => e.RequestedAt) - .ToListAsync(cancellationToken); + return await ExecuteAsync(async context => + { + var now = DateTime.UtcNow; + return await GetDbSet(context) + .AsNoTracking() + .Include(e => e.FunctionConfiguration) + .Where(e => e.LeasedBy != null + && e.LeaseExpiryTime < now + && (e.State == ExecutionState.Pending || e.State == ExecutionState.Running)) + .ToListAsync(cancellationToken); + }, cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting executions with expired leases"); + throw; + } + } + + /// + public async Task> GetReadyForRetryAsync(CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => + { + var now = DateTime.UtcNow; + return await GetDbSet(context) + .AsNoTracking() + .Include(e => e.FunctionConfiguration) + .Where(e => e.State == ExecutionState.Failed + && e.NextRetryAt != null + && e.NextRetryAt <= now) + .ToListAsync(cancellationToken); + }, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error getting executions with state {State}", LogSanitizer.SanitizeObject(state)); + Logger.LogError(ex, "Error getting executions ready for retry"); throw; } } + #endregion + + #region Leasing Operations + + /// public async Task LeaseNextPendingAsync(string workerId, TimeSpan leaseDuration, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(workerId)) @@ -112,8 +170,8 @@ public async Task> GetByStateAsync(ExecutionState state, try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); try { @@ -121,7 +179,7 @@ public async Task> GetByStateAsync(ExecutionState state, var leaseExpiry = now.Add(leaseDuration); // Find the next pending execution that is not leased or has an expired lease - var execution = await dbContext.FunctionExecutions + var execution = await GetDbSet(context) .Where(e => e.State == ExecutionState.Pending && (e.LeasedBy == null || e.LeaseExpiryTime < now)) .OrderBy(e => e.RequestedAt) @@ -138,10 +196,10 @@ public async Task> GetByStateAsync(ExecutionState state, execution.LeaseExpiryTime = leaseExpiry; execution.Version++; - await dbContext.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - _logger.LogInformation("Leased execution {ExecutionId} to worker {WorkerId} until {LeaseExpiry}", + Logger.LogInformation("Leased execution {ExecutionId} to worker {WorkerId} until {LeaseExpiry}", execution.Id, workerId, leaseExpiry); return execution; @@ -150,80 +208,38 @@ public async Task> GetByStateAsync(ExecutionState state, { // Another worker grabbed this execution, that's okay await transaction.RollbackAsync(cancellationToken); - _logger.LogDebug(ex, "Concurrency conflict while leasing execution (another worker may have claimed it)"); + Logger.LogDebug(ex, "Concurrency conflict while leasing execution (another worker may have claimed it)"); return null; } catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Error leasing next pending execution for worker {WorkerId}", - LogSanitizer.SanitizeObject(workerId)); + Logger.LogError(ex, "Error leasing next pending execution for worker {WorkerId}", + LoggingSanitizer.S(workerId)); throw; } } - catch (Exception ex) + catch (Exception ex) when (ex is not DbUpdateConcurrencyException) { - _logger.LogError(ex, "Error in LeaseNextPendingAsync for worker {WorkerId}", - LogSanitizer.SanitizeObject(workerId)); + Logger.LogError(ex, "Error in LeaseNextPendingAsync for worker {WorkerId}", + LoggingSanitizer.S(workerId)); throw; } } - public async Task> GetExpiredLeasesAsync(CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var now = DateTime.UtcNow; - - return await dbContext.FunctionExecutions - .AsNoTracking() - .Include(e => e.FunctionConfiguration) - .Where(e => e.LeasedBy != null - && e.LeaseExpiryTime < now - && (e.State == ExecutionState.Pending || e.State == ExecutionState.Running)) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting executions with expired leases"); - throw; - } - } + #endregion - public async Task> GetReadyForRetryAsync(CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var now = DateTime.UtcNow; - - return await dbContext.FunctionExecutions - .AsNoTracking() - .Include(e => e.FunctionConfiguration) - .Where(e => e.State == ExecutionState.Failed - && e.NextRetryAt != null - && e.NextRetryAt <= now) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting executions ready for retry"); - throw; - } - } + #region Create/Update Operations - public async Task CreateAsync(FunctionExecution execution, CancellationToken cancellationToken = default) + /// + public override async Task CreateAsync(FunctionExecution execution, CancellationToken cancellationToken = default) { - if (execution == null) - { - throw new ArgumentNullException(nameof(execution)); - } + ArgumentNullException.ThrowIfNull(execution); try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); try { @@ -232,9 +248,8 @@ public async Task CreateAsync(FunctionExecution execution, CancellationTok execution.Id = Guid.NewGuid(); } - dbContext.FunctionExecutions.Add(execution); - await dbContext.SaveChangesAsync(cancellationToken); - + GetDbSet(context).Add(execution); + await context.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return execution.Id; @@ -242,33 +257,31 @@ public async Task CreateAsync(FunctionExecution execution, CancellationTok catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while creating function execution"); + Logger.LogError(ex, "Transaction rolled back while creating function execution"); throw; } } catch (DbUpdateException ex) { - _logger.LogError(ex, "Database error creating function execution"); + Logger.LogError(ex, "Database error creating {EntityType}", EntityTypeName); throw; } catch (Exception ex) { - _logger.LogError(ex, "Error creating function execution"); + Logger.LogError(ex, "Error creating {EntityType}", EntityTypeName); throw; } } - public async Task UpdateAsync(FunctionExecution execution, CancellationToken cancellationToken = default) + /// + public override async Task UpdateAsync(FunctionExecution execution, CancellationToken cancellationToken = default) { - if (execution == null) - { - throw new ArgumentNullException(nameof(execution)); - } + ArgumentNullException.ThrowIfNull(execution); try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); try { @@ -277,13 +290,13 @@ public async Task UpdateAsync(FunctionExecution execution, CancellationTok execution.Version++; // Attach and update - dbContext.FunctionExecutions.Attach(execution); - dbContext.Entry(execution).State = EntityState.Modified; + GetDbSet(context).Attach(execution); + context.Entry(execution).State = EntityState.Modified; // Set original version for concurrency check - dbContext.Entry(execution).Property(e => e.Version).OriginalValue = originalVersion; + context.Entry(execution).Property(e => e.Version).OriginalValue = originalVersion; - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); + int rowsAffected = await context.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return rowsAffected > 0; @@ -291,36 +304,36 @@ public async Task UpdateAsync(FunctionExecution execution, CancellationTok catch (DbUpdateConcurrencyException ex) { await transaction.RollbackAsync(cancellationToken); - _logger.LogWarning(ex, "Concurrency conflict updating execution {ExecutionId}", - execution.Id); + Logger.LogWarning(ex, "Concurrency conflict updating execution {ExecutionId}", execution.Id); return false; } catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while updating function execution {ExecutionId}", - LogSanitizer.SanitizeObject(execution.Id)); + Logger.LogError(ex, "Transaction rolled back while updating {EntityType} {ExecutionId}", + EntityTypeName, LoggingSanitizer.S(execution.Id)); throw; } } - catch (Exception ex) + catch (Exception ex) when (ex is not DbUpdateConcurrencyException) { - _logger.LogError(ex, "Error updating function execution {ExecutionId}", - LogSanitizer.SanitizeObject(execution.Id)); + Logger.LogError(ex, "Error updating {EntityType} {ExecutionId}", + EntityTypeName, LoggingSanitizer.S(execution.Id)); throw; } } + /// public async Task UpdateStateAsync(Guid executionId, ExecutionState state, string? errorMessage = null, CancellationToken cancellationToken = default) { try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); try { - var execution = await dbContext.FunctionExecutions + var execution = await GetDbSet(context) .FirstOrDefaultAsync(e => e.Id == executionId, cancellationToken); if (execution != null) @@ -342,7 +355,7 @@ public async Task UpdateStateAsync(Guid executionId, ExecutionState state, strin } } - await dbContext.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); } await transaction.CommitAsync(cancellationToken); @@ -350,26 +363,27 @@ public async Task UpdateStateAsync(Guid executionId, ExecutionState state, strin catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while updating state for execution {ExecutionId}", - LogSanitizer.SanitizeObject(executionId)); + Logger.LogError(ex, "Transaction rolled back while updating state for execution {ExecutionId}", + LoggingSanitizer.S(executionId)); throw; } } catch (Exception ex) { - _logger.LogError(ex, "Error updating state for execution {ExecutionId}", - LogSanitizer.SanitizeObject(executionId)); + Logger.LogError(ex, "Error updating state for execution {ExecutionId}", + LoggingSanitizer.S(executionId)); throw; } } + /// public async Task UpdateProgressAsync(Guid executionId, int progressPercentage, string? statusMessage = null, CancellationToken cancellationToken = default) { try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); - var execution = await dbContext.FunctionExecutions + var execution = await GetDbSet(context) .FirstOrDefaultAsync(e => e.Id == executionId, cancellationToken); if (execution != null) @@ -378,36 +392,41 @@ public async Task UpdateProgressAsync(Guid executionId, int progressPercentage, execution.StatusMessage = statusMessage; execution.Version++; - await dbContext.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); } } catch (Exception ex) { - _logger.LogError(ex, "Error updating progress for execution {ExecutionId}", - LogSanitizer.SanitizeObject(executionId)); + Logger.LogError(ex, "Error updating progress for execution {ExecutionId}", + LoggingSanitizer.S(executionId)); // Don't throw - progress updates are non-critical } } + #endregion + + #region Cleanup Operations + + /// public async Task DeleteOldExecutionsAsync(DateTime olderThan, CancellationToken cancellationToken = default) { try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); try { - var oldExecutions = await dbContext.FunctionExecutions + var oldExecutions = await GetDbSet(context) .Where(e => e.RequestedAt < olderThan) .ToListAsync(cancellationToken); - dbContext.FunctionExecutions.RemoveRange(oldExecutions); - int count = await dbContext.SaveChangesAsync(cancellationToken); + GetDbSet(context).RemoveRange(oldExecutions); + int count = await context.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - _logger.LogInformation("Deleted {Count} old function executions older than {OlderThan}", + Logger.LogInformation("Deleted {Count} old function executions older than {OlderThan}", count, olderThan); return count; @@ -415,14 +434,16 @@ public async Task DeleteOldExecutionsAsync(DateTime olderThan, Cancellation catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while deleting old executions"); + Logger.LogError(ex, "Transaction rolled back while deleting old executions"); throw; } } catch (Exception ex) { - _logger.LogError(ex, "Error deleting old executions"); + Logger.LogError(ex, "Error deleting old executions"); throw; } } + + #endregion } diff --git a/Shared/ConduitLLM.Configuration/Repositories/GlobalSettingRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/GlobalSettingRepository.cs index 30cc1e6e8..6d174c27c 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/GlobalSettingRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/GlobalSettingRepository.cs @@ -1,274 +1,127 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Utilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for global settings using Entity Framework Core. +/// Inherits common CRUD operations from RepositoryBase. +/// +public class GlobalSettingRepository : RepositoryBase, IGlobalSettingRepository { /// - /// Repository implementation for global settings using Entity Framework Core + /// Creates a new instance of the repository. /// - public class GlobalSettingRepository : IGlobalSettingRepository + /// The database context factory + /// The logger + public GlobalSettingRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; + } - /// - /// Creates a new instance of the repository - /// - /// The database context factory - /// The logger - public GlobalSettingRepository( - IDbContextFactory dbContextFactory, - ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.GlobalSettings; - /// - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.GlobalSettings - .AsNoTracking() - .FirstOrDefaultAsync(gs => gs.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting global setting with ID {SettingId}", id); - throw; - } - } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderBy(gs => gs.Key); + } - /// - public async Task GetByKeyAsync(string key, CancellationToken cancellationToken = default) + /// + public async Task GetByKeyAsync(string key, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(key)) { - if (string.IsNullOrEmpty(key)) - { - throw new ArgumentException("Key cannot be null or empty", nameof(key)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.GlobalSettings - .AsNoTracking() - .FirstOrDefaultAsync(gs => gs.Key == key, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting global setting with key {SettingKey}", LoggingSanitizer.S(key)); - throw; - } + throw new ArgumentException("Key cannot be null or empty", nameof(key)); } - /// - public async Task> GetAllAsync(CancellationToken cancellationToken = default) + return await ExecuteAsync(async context => { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.GlobalSettings - .AsNoTracking() - .OrderBy(gs => gs.Key) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all global settings"); - throw; - } - } + return await GetDbSet(context) + .AsNoTracking() + .FirstOrDefaultAsync(gs => gs.Key == key, cancellationToken); + }, cancellationToken, $"getting by key {LoggingSanitizer.S(key)}"); + } - /// - public async Task CreateAsync(GlobalSetting globalSetting, CancellationToken cancellationToken = default) + /// + public async Task UpsertAsync(string key, string value, string? description = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(key)) { - if (globalSetting == null) - { - throw new ArgumentNullException(nameof(globalSetting)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Set timestamps - if (globalSetting.CreatedAt == default) - { - globalSetting.CreatedAt = DateTime.UtcNow; - } - - globalSetting.UpdatedAt = DateTime.UtcNow; - - dbContext.GlobalSettings.Add(globalSetting); - await dbContext.SaveChangesAsync(cancellationToken); - return globalSetting.Id; - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating global setting with key '{SettingKey}'", - LoggingSanitizer.S(globalSetting.Key)); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating global setting with key '{SettingKey}'", - LoggingSanitizer.S(globalSetting.Key)); - throw; - } + throw new ArgumentException("Key cannot be null or empty", nameof(key)); } - /// - public async Task UpdateAsync(GlobalSetting globalSetting, CancellationToken cancellationToken = default) - { - if (globalSetting == null) - { - throw new ArgumentNullException(nameof(globalSetting)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Ensure the entity is tracked - dbContext.GlobalSettings.Update(globalSetting); - - // Set the updated timestamp - globalSetting.UpdatedAt = DateTime.UtcNow; - - // Save changes - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (DbUpdateConcurrencyException ex) - { - _logger.LogError(ex, "Concurrency error updating global setting with ID {SettingId}", - globalSetting.Id); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating global setting with ID {SettingId}", - globalSetting.Id); - throw; - } - } + ArgumentNullException.ThrowIfNull(value); - /// - public async Task UpsertAsync(string key, string value, string? description = null, CancellationToken cancellationToken = default) + return await ExecuteAsync(async context => { - if (string.IsNullOrEmpty(key)) - { - throw new ArgumentException("Key cannot be null or empty", nameof(key)); - } + var dbSet = GetDbSet(context); - if (value == null) - { - throw new ArgumentNullException(nameof(value)); - } + // Try to find existing setting + var existingSetting = await dbSet + .FirstOrDefaultAsync(gs => gs.Key == key, cancellationToken); - try + if (existingSetting == null) { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Try to find existing setting - var existingSetting = await dbContext.GlobalSettings - .FirstOrDefaultAsync(gs => gs.Key == key, cancellationToken); - - if (existingSetting == null) + // Create new setting + var newSetting = new GlobalSetting { - // Create new setting - var newSetting = new GlobalSetting - { - Key = key, - Value = value, - Description = description, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; + Key = key, + Value = value, + Description = description, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; - dbContext.GlobalSettings.Add(newSetting); - } - else - { - // Update existing setting - existingSetting.Value = value; - existingSetting.UpdatedAt = DateTime.UtcNow; - - // Only update description if provided - if (description != null) - { - existingSetting.Description = description; - } - } - - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; + dbSet.Add(newSetting); } - catch (Exception ex) + else { - _logger.LogError(ex, "Error upserting global setting with key '{SettingKey}'", LoggingSanitizer.S(key)); - throw; - } - } - - /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var globalSetting = await dbContext.GlobalSettings.FindAsync(new object[] { id }, cancellationToken); + // Update existing setting + existingSetting.Value = value; + existingSetting.UpdatedAt = DateTime.UtcNow; - if (globalSetting == null) + // Only update description if provided + if (description != null) { - return false; + existingSetting.Description = description; } - - dbContext.GlobalSettings.Remove(globalSetting); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting global setting with ID {SettingId}", id); - throw; } - } - /// - public async Task DeleteByKeyAsync(string key, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(key)) - { - throw new ArgumentException("Key cannot be null or empty", nameof(key)); - } + int rowsAffected = await context.SaveChangesAsync(cancellationToken); + return rowsAffected > 0; + }, cancellationToken, $"upserting by key {LoggingSanitizer.S(key)}"); + } - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var globalSetting = await dbContext.GlobalSettings - .FirstOrDefaultAsync(gs => gs.Key == key, cancellationToken); + /// + public async Task DeleteByKeyAsync(string key, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + } - if (globalSetting == null) - { - return false; - } + return await ExecuteAsync(async context => + { + var dbSet = GetDbSet(context); + var globalSetting = await dbSet + .FirstOrDefaultAsync(gs => gs.Key == key, cancellationToken); - dbContext.GlobalSettings.Remove(globalSetting); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) + if (globalSetting == null) { - _logger.LogError(ex, "Error deleting global setting with key {SettingKey}", LoggingSanitizer.S(key)); - throw; + return false; } - } + + dbSet.Remove(globalSetting); + int rowsAffected = await context.SaveChangesAsync(cancellationToken); + return rowsAffected > 0; + }, cancellationToken, $"deleting by key {LoggingSanitizer.S(key)}"); } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/IModelAuthorRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/IModelAuthorRepository.cs deleted file mode 100644 index 9bff16443..000000000 --- a/Shared/ConduitLLM.Configuration/Repositories/IModelAuthorRepository.cs +++ /dev/null @@ -1,45 +0,0 @@ -using ConduitLLM.Configuration.Entities; - -namespace ConduitLLM.Configuration.Repositories -{ - /// - /// Repository interface for ModelAuthor entity operations. - /// - public interface IModelAuthorRepository - { - /// - /// Gets a model author by its ID. - /// - Task GetByIdAsync(int id); - - /// - /// Gets all model authors. - /// - Task> GetAllAsync(); - - /// - /// Gets a model author by name. - /// - Task GetByNameAsync(string name); - - /// - /// Gets series by author. - /// - Task?> GetSeriesByAuthorAsync(int authorId); - - /// - /// Creates a new model author. - /// - Task CreateAsync(ModelAuthor author); - - /// - /// Updates an existing model author. - /// - Task UpdateAsync(ModelAuthor author); - - /// - /// Deletes a model author by ID. - /// - Task DeleteAsync(int id); - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Repositories/IModelRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/IModelRepository.cs index d78a5450a..ffb9ddebf 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/IModelRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/IModelRepository.cs @@ -1,91 +1,123 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository interface for Model entity operations. +/// Models must be pre-created through seed data or admin operations. +/// Inherits standard CRUD operations from IRepositoryBase. +/// +public interface IModelRepository : IRepositoryBase { /// - /// Repository interface for Model entity operations. - /// Models must be pre-created through seed data or admin operations. + /// Gets a model by its ID, including related entities (Series, Author, Identifiers). /// - public interface IModelRepository - { - /// - /// Gets a model by its ID. - /// - Task GetByIdAsync(int id); - - /// - /// Gets a model by its ID, including related entities. - /// - Task GetByIdWithDetailsAsync(int id); - - /// - /// Gets all models. - /// - Task> GetAllAsync(); - - /// - /// Gets all models with their details (capabilities, series, etc.). - /// - Task> GetAllWithDetailsAsync(); + /// The model ID + /// Cancellation token + /// The model with details or null if not found + Task GetByIdWithDetailsAsync(int id, CancellationToken cancellationToken = default); - /// - /// Finds a model by its primary identifier. - /// - Task GetByIdentifierAsync(string identifier); + /// + /// Gets all models with their details (series, author, identifiers). + /// + /// Cancellation token + /// List of all models with details + Task> GetAllWithDetailsAsync(CancellationToken cancellationToken = default); - /// - /// Gets models by series. - /// - Task> GetBySeriesAsync(int seriesId); + /// + /// Finds a model by its primary identifier. + /// Searches ModelProviderTypeAssociation first, then falls back to model name. + /// + /// The model identifier to search for + /// Cancellation token + /// The model or null if not found + Task GetByIdentifierAsync(string identifier, CancellationToken cancellationToken = default); - /// - /// Creates a new model. - /// - Task CreateAsync(Model model); + /// + /// Gets models by series. + /// + /// The series ID + /// Cancellation token + /// List of models in the series + Task> GetBySeriesAsync(int seriesId, CancellationToken cancellationToken = default); - /// - /// Updates an existing model. - /// - Task UpdateAsync(Model model); + /// + /// Gets a model by its name. + /// + /// The model name + /// Cancellation token + /// The model or null if not found + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); - /// - /// Checks if a model exists. - /// - Task ExistsAsync(int id); + /// + /// Searches for active models by name (case-insensitive partial match). + /// + /// The search query + /// Cancellation token + /// List of matching models + Task> SearchByNameAsync(string query, CancellationToken cancellationToken = default); - /// - /// Gets a model by its name. - /// - Task GetByNameAsync(string name); + /// + /// Checks if a model has any mapping references. + /// + /// The model ID + /// Cancellation token + /// True if the model has mapping references + Task HasMappingReferencesAsync(int modelId, CancellationToken cancellationToken = default); - /// - /// Searches for models by name. - /// - Task> SearchByNameAsync(string query); + /// + /// Gets models available from a specific provider. + /// Filters based on ModelProviderTypeAssociation entries with matching provider. + /// + /// The provider type (e.g., OpenAI, Anthropic) + /// Cancellation token + /// List of models for the provider + Task> GetByProviderAsync(ProviderType providerType, CancellationToken cancellationToken = default); - /// - /// Checks if a model has any mapping references. - /// - Task HasMappingReferencesAsync(int modelId); + /// + /// Deletes a model identifier by ID. + /// + /// The model ID + /// The identifier ID to delete + /// Cancellation token + /// True if deleted, false if not found + Task DeleteIdentifierAsync(int modelId, int identifierId, CancellationToken cancellationToken = default); - /// - /// Deletes a model by ID. - /// - Task DeleteAsync(int id); + /// + /// Gets all models with details, supporting optional pagination, search, and capability filtering. + /// When page/pageSize are provided, returns paginated results. + /// + /// Page number (1-based), or null for all results + /// Items per page, or null for all results + /// Optional search term for model name (case-insensitive partial match) + /// Optional capability filter (chat, vision, image, video, embeddings) + /// Optional filter for models with/without provider identifiers + /// Cancellation token + /// Tuple of models list and total count + Task<(List Items, int TotalCount)> GetPaginatedWithFilterAsync( + int? page = null, + int? pageSize = null, + string? search = null, + string? capability = null, + bool? hasProviders = null, + CancellationToken cancellationToken = default); - /// - /// Gets models available from a specific provider. - /// Filters based on ModelIdentifier entries with matching provider. - /// - /// The provider name (e.g., "groq", "openai", "anthropic") - Task> GetByProviderAsync(ProviderType providerType); + /// + /// Creates a new model and returns the created entity. + /// Use this when you need the full entity back after creation. + /// + /// The model to create + /// Cancellation token + /// The created model with its assigned ID + Task CreateModelAsync(Model model, CancellationToken cancellationToken = default); - /// - /// Deletes a model identifier by ID. - /// - /// The model ID - /// The identifier ID to delete - /// True if deleted, false if not found - Task DeleteIdentifierAsync(int modelId, int identifierId); - } -} \ No newline at end of file + /// + /// Updates an existing model and returns the updated entity. + /// Use this when you need the full entity back after update. + /// + /// The model to update + /// Cancellation token + /// The updated model + Task UpdateModelAsync(Model model, CancellationToken cancellationToken = default); +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/IModelSeriesRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/IModelSeriesRepository.cs index 38818a482..bc8e0cd99 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/IModelSeriesRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/IModelSeriesRepository.cs @@ -1,55 +1,61 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository interface for ModelSeries entity operations. +/// Inherits standard CRUD operations from IRepositoryBase. +/// +public interface IModelSeriesRepository : IRepositoryBase { /// - /// Repository interface for ModelSeries entity operations. + /// Gets a model series by its ID with author information. /// - public interface IModelSeriesRepository - { - /// - /// Gets a model series by its ID. - /// - Task GetByIdAsync(int id); - - /// - /// Gets a model series by its ID with author. - /// - Task GetByIdWithAuthorAsync(int id); - - /// - /// Gets all model series. - /// - Task> GetAllAsync(); - - /// - /// Gets all model series with author information. - /// - Task> GetAllWithAuthorAsync(); + /// The series ID + /// Cancellation token + /// The model series with author or null if not found + Task GetByIdWithAuthorAsync(int id, CancellationToken cancellationToken = default); - /// - /// Gets a model series by name and author. - /// - Task GetByNameAndAuthorAsync(string name, int authorId); + /// + /// Gets all model series with author information. + /// + /// Cancellation token + /// List of all model series with author + Task> GetAllWithAuthorAsync(CancellationToken cancellationToken = default); - /// - /// Gets models in a series. - /// - Task?> GetModelsInSeriesAsync(int seriesId); + /// + /// Gets a model series by name and author. + /// + /// The series name + /// The author ID + /// Cancellation token + /// The model series or null if not found + Task GetByNameAndAuthorAsync(string name, int authorId, CancellationToken cancellationToken = default); - /// - /// Creates a new model series. - /// - Task CreateAsync(ModelSeries series); + /// + /// Gets models in a series. + /// + /// The series ID + /// Cancellation token + /// List of models in the series or null if series not found + Task?> GetModelsInSeriesAsync(int seriesId, CancellationToken cancellationToken = default); - /// - /// Updates an existing model series. - /// - Task UpdateAsync(ModelSeries series); + /// + /// Creates a new model series and returns the created entity. + /// Use this when you need the full entity back after creation. + /// + /// The series to create + /// Cancellation token + /// The created model series with its assigned ID + Task CreateSeriesAsync(ModelSeries series, CancellationToken cancellationToken = default); - /// - /// Deletes a model series by ID. - /// - Task DeleteAsync(int id); - } -} \ No newline at end of file + /// + /// Updates an existing model series and returns the updated entity. + /// Use this when you need the full entity back after update. + /// + /// The series to update + /// Cancellation token + /// The updated model series + Task UpdateSeriesAsync(ModelSeries series, CancellationToken cancellationToken = default); +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/IpFilterRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/IpFilterRepository.cs new file mode 100644 index 000000000..deb61d870 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Repositories/IpFilterRepository.cs @@ -0,0 +1,67 @@ +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Configuration.Utilities; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for IP filter management using Entity Framework Core. +/// Inherits common CRUD operations from RepositoryBase. +/// +public class IpFilterRepository : RepositoryBase, IIpFilterRepository +{ + /// + /// Creates a new instance of the repository. + /// + /// The database context factory + /// The logger + public IpFilterRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) + { + } + + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.IpFilters; + + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query + .OrderBy(f => f.FilterType) + .ThenBy(f => f.IpAddressOrCidr); + } + + /// + public async Task> GetEnabledAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + { + return await GetDbSet(context) + .AsNoTracking() + .Where(f => f.IsEnabled) + .OrderBy(f => f.FilterType) + .ThenBy(f => f.IpAddressOrCidr) + .ToListAsync(cancellationToken); + }, cancellationToken, "getting enabled filters"); + } + + /// + public async Task AddAsync(IpFilterEntity filter, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + + // Base CreateAsync already handles error logging + await CreateAsync(filter, cancellationToken); + + Logger.LogInformation("Added new IP filter: {FilterType} {IpAddressOrCidr}", + LoggingSanitizer.S(filter.FilterType), + LoggingSanitizer.S(filter.IpAddressOrCidr)); + + return filter; + } +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/MediaRecordRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/MediaRecordRepository.cs index dcbce67ef..c1fbd1335 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/MediaRecordRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/MediaRecordRepository.cs @@ -1,217 +1,243 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; + using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for media record operations. +/// Extends RepositoryBase for standard CRUD operations and implements domain-specific methods. +/// +public class MediaRecordRepository : RepositoryBase, IMediaRecordRepository { /// - /// Repository implementation for media record operations. + /// Creates a new instance of the repository. /// - public class MediaRecordRepository : IMediaRecordRepository - { - private readonly IDbContextFactory _contextFactory; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the MediaRecordRepository class. - /// - /// The database context factory. - /// The logger instance. - public MediaRecordRepository( - IDbContextFactory contextFactory, - ILogger logger) - { - _contextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + /// The database context factory. + /// The logger instance. + public MediaRecordRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) + { + } - /// - public async Task CreateAsync(MediaRecord mediaRecord) - { - ArgumentNullException.ThrowIfNull(mediaRecord); - - using var context = await _contextFactory.CreateDbContextAsync(); - - context.MediaRecords.Add(mediaRecord); - await context.SaveChangesAsync(); - - _logger.LogInformation("Created media record {Id} for virtual key {VirtualKeyId}", - mediaRecord.Id, mediaRecord.VirtualKeyId); - - return mediaRecord; - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.MediaRecords; - /// - public async Task GetByIdAsync(Guid id) + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + { + return query.Include(m => m.VirtualKey); + } + + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderByDescending(m => m.CreatedAt); + } + + /// + protected override void OnBeforeCreate(MediaRecord entity) + { + base.OnBeforeCreate(entity); + + // Set CreatedAt if not provided + if (entity.CreatedAt == default) { - using var context = await _contextFactory.CreateDbContextAsync(); - - return await context.MediaRecords - .Include(m => m.VirtualKey) - .FirstOrDefaultAsync(m => m.Id == id); + entity.CreatedAt = DateTime.UtcNow; } + } - /// - public async Task GetByStorageKeyAsync(string storageKey) + /// + public async Task GetByStorageKeyAsync(string storageKey, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(storageKey)) { - if (string.IsNullOrWhiteSpace(storageKey)) - return null; - - using var context = await _contextFactory.CreateDbContextAsync(); - - return await context.MediaRecords - .Include(m => m.VirtualKey) - .FirstOrDefaultAsync(m => m.StorageKey == storageKey); + return null; } - /// - public async Task> GetByVirtualKeyIdAsync(int virtualKeyId) - { - using var context = await _contextFactory.CreateDbContextAsync(); - - return await context.MediaRecords + return await ExecuteAsync(async context => + await ApplyDefaultIncludes(GetDbSet(context).AsNoTracking()) + .FirstOrDefaultAsync(m => m.StorageKey == storageKey, cancellationToken), + cancellationToken, $"getting by storage key {storageKey}"); + } + + /// + public async Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() .Where(m => m.VirtualKeyId == virtualKeyId) .OrderByDescending(m => m.CreatedAt) - .ToListAsync(); - } + .ToListAsync(cancellationToken), + cancellationToken, $"getting by virtual key ID {virtualKeyId}"); + } - /// - public async Task> GetExpiredMediaAsync(DateTime currentTime) - { - using var context = await _contextFactory.CreateDbContextAsync(); - - return await context.MediaRecords + /// + public async Task> GetExpiredMediaAsync(DateTime currentTime, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() .Where(m => m.ExpiresAt != null && m.ExpiresAt <= currentTime) - .ToListAsync(); - } + .ToListAsync(cancellationToken), + cancellationToken, "getting expired media"); + } - /// - public async Task> GetMediaOlderThanAsync(DateTime cutoffDate) - { - using var context = await _contextFactory.CreateDbContextAsync(); - - return await context.MediaRecords + /// + public async Task> GetMediaOlderThanAsync(DateTime cutoffDate, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() .Where(m => m.CreatedAt < cutoffDate) - .ToListAsync(); - } + .ToListAsync(cancellationToken), + cancellationToken, $"getting media older than {cutoffDate:d}"); + } - /// - public async Task> GetOrphanedMediaAsync() + /// + public async Task> GetOrphanedMediaAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _contextFactory.CreateDbContextAsync(); - // Find media records where the virtual key no longer exists - var orphanedMedia = await context.MediaRecords + var orphanedMedia = await GetDbSet(context) + .AsNoTracking() .Where(m => !context.VirtualKeys.Any(vk => vk.Id == m.VirtualKeyId)) - .ToListAsync(); - - if (orphanedMedia.Count() > 0) + .ToListAsync(cancellationToken); + + if (orphanedMedia.Count > 0) { - _logger.LogWarning("Found {Count} orphaned media records", orphanedMedia.Count); + Logger.LogWarning("Found {Count} orphaned media records", orphanedMedia.Count); } - + return orphanedMedia; - } + }, cancellationToken, "getting orphaned media"); + } - /// - public async Task UpdateAccessStatsAsync(Guid id) + /// + public async Task UpdateAccessStatsAsync(Guid id, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _contextFactory.CreateDbContextAsync(); - - var mediaRecord = await context.MediaRecords.FindAsync(id); + var mediaRecord = await GetDbSet(context).FindAsync(new object[] { id }, cancellationToken); if (mediaRecord == null) + { return false; - + } + mediaRecord.AccessCount++; mediaRecord.LastAccessedAt = DateTime.UtcNow; - - await context.SaveChangesAsync(); - return true; - } - /// - public async Task DeleteAsync(Guid id) - { - using var context = await _contextFactory.CreateDbContextAsync(); - - var mediaRecord = await context.MediaRecords.FindAsync(id); - if (mediaRecord == null) - return false; - - context.MediaRecords.Remove(mediaRecord); - await context.SaveChangesAsync(); - - _logger.LogInformation("Deleted media record {Id}", id); + await context.SaveChangesAsync(cancellationToken); return true; - } + }, cancellationToken, $"updating access stats for ID {id}"); + } - /// - public async Task DeleteManyAsync(IEnumerable ids) + /// + public async Task DeleteManyAsync(IEnumerable ids, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _contextFactory.CreateDbContextAsync(); - var idList = ids.ToList(); - var mediaRecords = await context.MediaRecords + var mediaRecords = await GetDbSet(context) .Where(m => idList.Contains(m.Id)) - .ToListAsync(); - - if (mediaRecords.Count() > 0) + .ToListAsync(cancellationToken); + + if (mediaRecords.Count > 0) { - context.MediaRecords.RemoveRange(mediaRecords); - await context.SaveChangesAsync(); - - _logger.LogInformation("Deleted {Count} media records", mediaRecords.Count); + GetDbSet(context).RemoveRange(mediaRecords); + await context.SaveChangesAsync(cancellationToken); + + Logger.LogInformation("Deleted {Count} media records", mediaRecords.Count); } - + return mediaRecords.Count; - } + }, cancellationToken, "deleting multiple"); + } - /// - public async Task GetTotalStorageSizeByVirtualKeyAsync(int virtualKeyId) - { - using var context = await _contextFactory.CreateDbContextAsync(); - - return await context.MediaRecords + /// + public async Task GetTotalStorageSizeByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) .Where(m => m.VirtualKeyId == virtualKeyId && m.SizeBytes.HasValue) - .SumAsync(m => m.SizeBytes ?? 0); - } + .SumAsync(m => m.SizeBytes ?? 0, cancellationToken), + cancellationToken, $"getting total storage size for virtual key {virtualKeyId}"); + } - /// - public async Task> GetStorageStatsByProviderAsync() - { - using var context = await _contextFactory.CreateDbContextAsync(); - - var stats = await context.MediaRecords + /// + public async Task> GetStorageStatsByProviderAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) .Where(m => m.Provider != null && m.SizeBytes.HasValue) .GroupBy(m => m.Provider!) .Select(g => new { Provider = g.Key, TotalSize = g.Sum(m => m.SizeBytes ?? 0) }) - .ToDictionaryAsync(x => x.Provider, x => x.TotalSize); - - return stats; - } + .ToDictionaryAsync(x => x.Provider, x => x.TotalSize, cancellationToken), + cancellationToken, "getting storage stats by provider"); + } - /// - public async Task> GetStorageStatsByMediaTypeAsync() - { - using var context = await _contextFactory.CreateDbContextAsync(); - - var stats = await context.MediaRecords + /// + public async Task> GetStorageStatsByMediaTypeAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) .Where(m => m.SizeBytes.HasValue) .GroupBy(m => m.MediaType) .Select(g => new { MediaType = g.Key, TotalSize = g.Sum(m => m.SizeBytes ?? 0) }) - .ToDictionaryAsync(x => x.MediaType, x => x.TotalSize); - - return stats; + .ToDictionaryAsync(x => x.MediaType, x => x.TotalSize, cancellationToken), + cancellationToken, "getting storage stats by media type"); + } + + /// + public async Task GetCountByVirtualKeyAsync(int virtualKeyId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .CountAsync(m => m.VirtualKeyId == virtualKeyId, cancellationToken), + cancellationToken, $"getting count for virtual key {virtualKeyId}"); + } + + /// + public async Task> SearchByStorageKeyPatternAsync(string storageKeyPattern, int maxResults = 100, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(storageKeyPattern)) + { + return new List(); } - /// - public async Task GetCountByVirtualKeyAsync(int virtualKeyId) + // Ensure maxResults is within reasonable bounds + if (maxResults <= 0) + { + maxResults = 100; + } + else if (maxResults > 1000) { - using var context = await _contextFactory.CreateDbContextAsync(); - - return await context.MediaRecords - .CountAsync(m => m.VirtualKeyId == virtualKeyId); + maxResults = 1000; } + + // Escape special characters in the pattern for LIKE/ILIKE + var escapedPattern = storageKeyPattern + .Replace("\\", "\\\\") + .Replace("%", "\\%") + .Replace("_", "\\_"); + + // Use ILIKE for case-insensitive pattern matching in PostgreSQL + var likePattern = $"%{escapedPattern}%"; + + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Where(m => EF.Functions.ILike(m.StorageKey, likePattern)) + .OrderByDescending(m => m.CreatedAt) + .Take(maxResults) + .ToListAsync(cancellationToken), + cancellationToken, "searching by storage key pattern"); } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/ModelAuthorRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/ModelAuthorRepository.cs index d1ee1e685..430dfa298 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/ModelAuthorRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/ModelAuthorRepository.cs @@ -1,86 +1,65 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for model authors using Entity Framework Core. +/// Inherits common CRUD operations from RepositoryBase. +/// +public class ModelAuthorRepository : RepositoryBase, IModelAuthorRepository { /// - /// Repository for ModelAuthor entity operations. + /// Creates a new instance of the repository. /// - public class ModelAuthorRepository : IModelAuthorRepository + /// The database context factory + /// The logger + public ModelAuthorRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly IDbContextFactory _dbContextFactory; + } - public ModelAuthorRepository(IDbContextFactory dbContextFactory) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.ModelAuthors; - public async Task GetByIdAsync(int id) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .FirstOrDefaultAsync(a => a.Id == id); - } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderBy(a => a.Name); + } - public async Task> GetAllAsync() + /// + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .OrderBy(a => a.Name) - .ToListAsync(); - } + return await GetDbSet(context) + .AsNoTracking() + .FirstOrDefaultAsync(a => a.Name == name, cancellationToken); + }, cancellationToken, $"getting by name {name}"); + } - public async Task GetByNameAsync(string name) + /// + public async Task?> GetSeriesByAuthorAsync(int authorId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .FirstOrDefaultAsync(a => a.Name == name); - } + var exists = await GetDbSet(context) + .AnyAsync(a => a.Id == authorId, cancellationToken); - public async Task?> GetSeriesByAuthorAsync(int authorId) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - var exists = await context.Set() - .AnyAsync(a => a.Id == authorId); - if (!exists) return null; - return await context.Set() + return await context.ModelSeries + .AsNoTracking() .Where(s => s.AuthorId == authorId) .OrderBy(s => s.Name) - .ToListAsync(); - } - - public async Task CreateAsync(ModelAuthor author) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - context.Set().Add(author); - await context.SaveChangesAsync(); - return author; - } - - public async Task UpdateAsync(ModelAuthor author) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - context.Set().Update(author); - await context.SaveChangesAsync(); - return author; - } - - public async Task DeleteAsync(int id) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - var author = await context.Set() - .FirstOrDefaultAsync(a => a.Id == id); - - if (author == null) - return false; - - context.Set().Remove(author); - await context.SaveChangesAsync(); - return true; - } + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting series for author ID {authorId}"); } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/ModelCostRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/ModelCostRepository.cs index 19647ec2c..1e0823d83 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/ModelCostRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/ModelCostRepository.cs @@ -1,10 +1,9 @@ using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Utilities; +using ConduitLLM.Configuration.Interfaces; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Configuration.Repositories { /// @@ -13,385 +12,142 @@ namespace ConduitLLM.Configuration.Repositories /// /// /// This repository provides data access operations for model cost entities using Entity Framework Core. - /// It implements the interface and provides concrete implementations - /// for all required operations. - /// - /// - /// The implementation follows these principles: + /// It extends RepositoryBase for standard CRUD operations and implements domain-specific methods + /// from IModelCostRepository. /// - /// - /// Using short-lived DbContext instances for better performance and reliability - /// Comprehensive error handling with detailed logging - /// Optimistic concurrency control for update operations - /// Non-tracking queries for read operations to improve performance - /// Automatic timestamp management for auditing purposes - /// Transaction-based operations for data consistency - /// /// /// ModelCost entities store pricing information for different LLM models, including input token costs, /// output token costs, and additional costs for specific operations like embeddings or image generation. /// This repository enables the application to manage these cost records and calculate usage expenses. /// /// - public class ModelCostRepository : IModelCostRepository + public class ModelCostRepository : RepositoryBase, IModelCostRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - /// /// Initializes a new instance of the class. /// /// The database context factory used to create DbContext instances. /// The logger for recording diagnostic information. - /// Thrown when dbContextFactory or logger is null. - /// - /// This constructor initializes the repository with the required dependencies: - /// - /// - /// - /// A DbContext factory that creates ConfigurationDbContext instances for data access operations. - /// Using a factory pattern allows the repository to create short-lived context instances for - /// each operation, which is recommended for web applications. - /// - /// - /// - /// - /// A logger for capturing diagnostic information and errors during repository operations. - /// This is especially important for data access operations to help diagnose issues in production. - /// - /// - /// - /// public ModelCostRepository( IDbContextFactory dbContextFactory, ILogger logger) + : base(dbContextFactory, logger) { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + protected override DbSet GetDbSet(ConduitDbContext context) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.ModelCosts - .AsNoTracking() - .Include(m => m.ModelProviderTypeAssociations) - .ThenInclude(mpta => mpta.Model) - .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model cost with ID {CostId}", LogSanitizer.SanitizeObject(id)); - throw; - } + return context.ModelCosts; } /// - public async Task GetByCostNameAsync(string costName, CancellationToken cancellationToken = default) + protected override IQueryable ApplyDefaultIncludes(IQueryable query) { - if (string.IsNullOrWhiteSpace(costName)) - { - throw new ArgumentException("Cost name cannot be null or empty", nameof(costName)); - } + return query + .Include(m => m.ModelProviderTypeAssociations) + .ThenInclude(mpta => mpta.Model); + } - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.ModelCosts - .AsNoTracking() - .Include(m => m.ModelProviderTypeAssociations) - .ThenInclude(mpta => mpta.Model) - .FirstOrDefaultAsync(m => m.CostName == costName, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model cost with name {CostName}", LogSanitizer.SanitizeObject(costName)); - throw; - } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderBy(m => m.CostName); } /// - public async Task> GetAllAsync(CancellationToken cancellationToken = default) + public async Task GetByCostNameAsync(string costName, CancellationToken cancellationToken = default) { - try + if (string.IsNullOrWhiteSpace(costName)) { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.ModelCosts - .AsNoTracking() - .Include(m => m.ModelProviderTypeAssociations) - .ThenInclude(mpta => mpta.Model) - .OrderBy(m => m.CostName) - .ToListAsync(cancellationToken); + throw new ArgumentException("Cost name cannot be null or empty", nameof(costName)); } - catch (Exception ex) + + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Error getting all model costs"); - throw; - } + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + return await query.FirstOrDefaultAsync(m => m.CostName == costName, cancellationToken); + }, cancellationToken); } /// - /// - /// - /// This implementation retrieves model costs associated with a specific provider by: - /// - /// - /// - /// Finding the provider's credential record by name - /// - /// - /// Retrieving all model mappings associated with that provider - /// - /// - /// Finding cost records that match the provider's model names exactly - /// - /// - /// Finding cost records with wildcard patterns that match the provider's models - /// - /// - /// Finding cost records that have the provider name in their pattern - /// - /// - /// - /// This approach ensures that all cost records related to a provider are returned, - /// even if they use different naming conventions or wildcard patterns. - /// - /// - - /// + [Obsolete("Use GetByProviderPaginatedAsync instead. This method loads all records into memory and will be removed in a future version.")] public async Task> GetByProviderAsync(int providerId, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // First, verify provider exists - var provider = await dbContext.Providers + // Verify provider exists + var provider = await context.Providers .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == providerId, cancellationToken); if (provider == null) { - _logger.LogWarning("No provider found with ID {ProviderId}", providerId); + Logger.LogWarning("No provider found with ID {ProviderId}", providerId); return new List(); } // Get all model mappings for this provider - var providerMappings = await dbContext.ModelProviderMappings + var providerMappings = await context.ModelProviderMappings .AsNoTracking() .Where(m => m.ProviderId == providerId) .ToListAsync(cancellationToken); - if (providerMappings.Count() == 0) + if (!providerMappings.Any()) { - _logger.LogInformation("No model mappings found for provider {ProviderId}", providerId); + Logger.LogInformation("No model mappings found for provider {ProviderId}", providerId); return new List(); } - // Get the list of model patterns used by this provider - var allModelPatterns = new List(); - // Extract provider model names from mappings for pattern matching - var exactModelNames = providerMappings.Select(m => m.ProviderModelId).ToList(); - - // Get all model costs - // Get all model costs that are associated with models from this provider - // Note: This needs to be refactored to work with the new ModelProviderTypeAssociation relationship - // The Provider concept may need to be mapped differently now - var costs = await dbContext.ModelCosts - .AsNoTracking() - .Include(m => m.ModelProviderTypeAssociations) - .ThenInclude(mpta => mpta.Model) - .Where(m => m.ModelProviderTypeAssociations.Any(mpta => - mpta.Provider != null && mpta.IsEnabled)) - .OrderBy(m => m.CostName) - .ToListAsync(cancellationToken); - - return costs; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model costs for provider {ProviderId}", providerId); - throw; - } - } - - /// - public async Task CreateAsync(ModelCost modelCost, CancellationToken cancellationToken = default) - { - if (modelCost == null) - { - throw new ArgumentNullException(nameof(modelCost)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Use a transaction to ensure atomicity - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - // Set timestamps - modelCost.CreatedAt = DateTime.UtcNow; - modelCost.UpdatedAt = DateTime.UtcNow; - - dbContext.ModelCosts.Add(modelCost); - await dbContext.SaveChangesAsync(cancellationToken); + // Get model costs associated with models from this provider + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + query = query.Where(m => m.ModelProviderTypeAssociations.Any(mpta => + mpta.Provider != null && mpta.IsEnabled)); + query = ApplyDefaultOrdering(query); - // Commit the transaction - await transaction.CommitAsync(cancellationToken); - - return modelCost.Id; - } - catch (Exception ex) - { - // Rollback the transaction on error - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while creating model cost '{CostName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(modelCost.CostName))); - throw; - } - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating model cost '{CostName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(modelCost.CostName))); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating model cost '{CostName}'", - LogSanitizer.SanitizeObject(LoggingSanitizer.S(modelCost.CostName))); - throw; - } + return await query.ToListAsync(cancellationToken); + }, cancellationToken); } /// - public async Task UpdateAsync(ModelCost modelCost, CancellationToken cancellationToken = default) + public async Task<(List Items, int TotalCount)> GetByProviderPaginatedAsync( + int providerId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) { - if (modelCost == null) - { - throw new ArgumentNullException(nameof(modelCost)); - } + (pageNumber, pageSize) = NormalizePagination(pageNumber, pageSize); - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Use a transaction to ensure atomicity - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - // Set updated timestamp - modelCost.UpdatedAt = DateTime.UtcNow; - - dbContext.ModelCosts.Update(modelCost); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - - // Commit the transaction - await transaction.CommitAsync(cancellationToken); - - return rowsAffected > 0; - } - catch (DbUpdateConcurrencyException ex) - { - // Rollback the transaction on error - await transaction.RollbackAsync(cancellationToken); - - _logger.LogError(ex, "Concurrency error updating model cost with ID {CostId}", LogSanitizer.SanitizeObject(modelCost.Id)); - - // Handle concurrency issues by reloading and reapplying changes with a new transaction - try - { - using var retryDbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var retryTransaction = await retryDbContext.Database.BeginTransactionAsync(cancellationToken); - - var existingEntity = await retryDbContext.ModelCosts.FindAsync(new object[] { modelCost.Id }, cancellationToken); - - if (existingEntity == null) - { - return false; - } - - // Update properties - retryDbContext.Entry(existingEntity).CurrentValues.SetValues(modelCost); - existingEntity.UpdatedAt = DateTime.UtcNow; - - int rowsAffected = await retryDbContext.SaveChangesAsync(cancellationToken); - - // Commit the retry transaction - await retryTransaction.CommitAsync(cancellationToken); + // Verify provider exists + var provider = await context.Providers + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == providerId, cancellationToken); - return rowsAffected > 0; - } - catch (Exception retryEx) - { - _logger.LogError(retryEx, "Error during retry of model cost update with ID {CostId}", LogSanitizer.SanitizeObject(modelCost.Id)); - throw; - } - } - catch (Exception ex) + if (provider == null) { - // Rollback the transaction on error - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while updating model cost with ID {CostId}", - LogSanitizer.SanitizeObject(modelCost.Id)); - throw; + Logger.LogWarning("No provider found with ID {ProviderId}", providerId); + return (new List(), 0); } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating model cost with ID {CostId}", - LogSanitizer.SanitizeObject(modelCost.Id)); - throw; - } - } - /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Use a transaction to ensure atomicity - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); - - try - { - var modelCost = await dbContext.ModelCosts.FindAsync(new object[] { id }, cancellationToken); - - if (modelCost == null) - { - return false; - } + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + query = query.Where(m => m.ModelProviderTypeAssociations.Any(mpta => + mpta.Provider != null && mpta.IsEnabled)); - dbContext.ModelCosts.Remove(modelCost); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); + var totalCount = await query.CountAsync(cancellationToken); - // Commit the transaction - await transaction.CommitAsync(cancellationToken); + query = ApplyDefaultOrdering(query); + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) - { - // Rollback the transaction on error - await transaction.RollbackAsync(cancellationToken); - _logger.LogError(ex, "Transaction rolled back while deleting model cost with ID {CostId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting model cost with ID {CostId}", LogSanitizer.SanitizeObject(id)); - throw; - } + return (items, totalCount); + }, cancellationToken); } } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/ModelProviderMappingRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/ModelProviderMappingRepository.cs index 624017696..e5ae4488d 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/ModelProviderMappingRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/ModelProviderMappingRepository.cs @@ -1,6 +1,6 @@ +using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Utilities; -using ModelProviderMappingEntity = ConduitLLM.Configuration.Entities.ModelProviderMapping; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -10,11 +10,8 @@ namespace ConduitLLM.Configuration.Repositories /// /// Repository implementation for model provider mappings using Entity Framework Core. /// - public class ModelProviderMappingRepository : IModelProviderMappingRepository + public class ModelProviderMappingRepository : RepositoryBase, IModelProviderMappingRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - /// /// Creates a new instance of the repository /// @@ -23,167 +20,125 @@ public class ModelProviderMappingRepository : IModelProviderMappingRepository public ModelProviderMappingRepository( IDbContextFactory dbContextFactory, ILogger logger) + : base(dbContextFactory, logger) { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - public async Task GetByIdAsync( - int id, - CancellationToken cancellationToken = default) + protected override DbSet GetDbSet(ConduitDbContext context) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.ModelProviderMappings - .Include(m => m.Provider) - .Include(m => m.ModelProviderTypeAssociation) - .ThenInclude(a => a.Model) - .ThenInclude(m => m.Series) - .AsNoTracking() - .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model provider mapping with ID {MappingId}", id); - throw; - } + return context.ModelProviderMappings; } /// - public async Task GetByModelNameAsync( - string modelName, - CancellationToken cancellationToken = default) + protected override IQueryable ApplyDefaultIncludes(IQueryable query) { - if (string.IsNullOrEmpty(modelName)) - { - throw new ArgumentException("Model name cannot be null or empty", nameof(modelName)); - } + return query + .Include(m => m.Provider) + .Include(m => m.ModelProviderTypeAssociation) + .ThenInclude(a => a.Model) + .ThenInclude(m => m.Series); + } - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.ModelProviderMappings - .Include(m => m.Provider) - .Include(m => m.ModelProviderTypeAssociation) - .ThenInclude(a => a.Model) - .AsNoTracking() - .FirstOrDefaultAsync(m => m.ModelAlias == modelName, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model provider mapping for model {ModelName}", LoggingSanitizer.S(modelName)); - throw; - } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderBy(m => m.ModelAlias); } /// - public async Task> GetAllAsync( + public async Task GetByModelNameAsync( + string modelName, CancellationToken cancellationToken = default) { - try + if (string.IsNullOrEmpty(modelName)) { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.ModelProviderMappings - .Include(m => m.Provider) - .Include(m => m.ModelProviderTypeAssociation) - .ThenInclude(a => a.Model) - .AsNoTracking() - .OrderBy(m => m.ModelAlias) - .ToListAsync(cancellationToken); + throw new ArgumentException("Model name cannot be null or empty", nameof(modelName)); } - catch (Exception ex) + + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Error getting all model provider mappings"); - throw; - } + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + return await query.FirstOrDefaultAsync(m => m.ModelAlias == modelName, cancellationToken); + }, cancellationToken, $"getting by model name {LoggingSanitizer.S(modelName)}"); } /// - public async Task> GetByProviderAsync( + [Obsolete("Use GetByProviderPaginatedAsync instead. This method loads all records into memory and will be removed in a future version.")] + public async Task> GetByProviderAsync( ProviderType providerType, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var credential = await dbContext.Providers + var credential = await context.Providers .AsNoTracking() .FirstOrDefaultAsync(pc => pc.ProviderType == providerType, cancellationToken); if (credential == null) { - return new List(); + return new List(); } // Then find mappings with this credential ID - return await dbContext.ModelProviderMappings - .Include(m => m.Provider) - .AsNoTracking() + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + return await query .Where(m => m.ProviderId == credential.Id) .OrderBy(m => m.ModelAlias) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting model provider mappings for provider type {ProviderType}", providerType); - throw; - } + }, cancellationToken, $"getting by provider type {providerType}"); } /// - public async Task CreateAsync( - ModelProviderMappingEntity modelProviderMapping, + public async Task<(List Items, int TotalCount)> GetByProviderPaginatedAsync( + int providerId, + int pageNumber, + int pageSize, CancellationToken cancellationToken = default) { - if (modelProviderMapping == null) - { - throw new ArgumentNullException(nameof(modelProviderMapping)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Set timestamps - modelProviderMapping.CreatedAt = DateTime.UtcNow; - modelProviderMapping.UpdatedAt = DateTime.UtcNow; - - dbContext.ModelProviderMappings.Add(modelProviderMapping); - await dbContext.SaveChangesAsync(cancellationToken); + return await GetFilteredPaginatedAsync( + m => m.ProviderId == providerId, + pageNumber, + pageSize, + q => q.OrderBy(m => m.ModelAlias), + cancellationToken, + $"getting paginated for provider {providerId}"); + } - return modelProviderMapping.Id; - } - catch (Exception ex) + /// + public async Task> GetByModelIdAsync( + int modelId, + CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Error creating model provider mapping for {ModelAlias}", LoggingSanitizer.S(modelProviderMapping.ModelAlias)); - throw; - } + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + return await query + .Where(m => m.ModelProviderTypeAssociation != null && m.ModelProviderTypeAssociation.ModelId == modelId) + .OrderBy(m => m.ModelAlias) + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting by model ID {modelId}"); } /// - public async Task UpdateAsync( - ModelProviderMappingEntity modelProviderMapping, + public override async Task UpdateAsync( + ModelProviderMapping modelProviderMapping, CancellationToken cancellationToken = default) { - if (modelProviderMapping == null) - { - throw new ArgumentNullException(nameof(modelProviderMapping)); - } + ArgumentNullException.ThrowIfNull(modelProviderMapping); - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - // Get existing entity to ensure it exists - var existingEntity = await dbContext.ModelProviderMappings + var existingEntity = await GetDbSet(context) .FirstOrDefaultAsync(m => m.Id == modelProviderMapping.Id, cancellationToken); if (existingEntity == null) { - _logger.LogWarning("Cannot update non-existent model provider mapping with ID {MappingId}", modelProviderMapping.Id); + Logger.LogWarning("Cannot update non-existent model provider mapping with ID {MappingId}", modelProviderMapping.Id); return false; } @@ -193,50 +148,17 @@ public async Task UpdateAsync( existingEntity.ProviderId = modelProviderMapping.ProviderId; existingEntity.IsEnabled = modelProviderMapping.IsEnabled; existingEntity.ModelProviderTypeAssociationId = modelProviderMapping.ModelProviderTypeAssociationId; - + existingEntity.UpdatedAt = DateTime.UtcNow; - _logger.LogInformation( + Logger.LogInformation( "Updating model mapping {ModelAlias} with AssociationId={AssociationId}", existingEntity.ModelAlias, existingEntity.ModelProviderTypeAssociationId); - await dbContext.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating model provider mapping with ID {MappingId}", modelProviderMapping.Id); - throw; - } - } - - /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var entity = await dbContext.ModelProviderMappings - .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); - - if (entity == null) - { - _logger.LogWarning("Cannot delete non-existent model provider mapping with ID {MappingId}", id); - return false; - } - - dbContext.ModelProviderMappings.Remove(entity); - await dbContext.SaveChangesAsync(cancellationToken); - - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting model provider mapping with ID {MappingId}", id); - throw; - } + }, cancellationToken, $"updating ID {modelProviderMapping.Id}"); } } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/ModelRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/ModelRepository.cs index 4aa0b1bb6..98a38de1d 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/ModelRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/ModelRepository.cs @@ -1,181 +1,323 @@ -using Microsoft.EntityFrameworkCore; using ConduitLLM.Configuration.Entities; -namespace ConduitLLM.Configuration.Repositories +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for Model entity operations. +/// Inherits common CRUD operations from RepositoryBase. +/// +public class ModelRepository : RepositoryBase, IModelRepository { /// - /// Repository implementation for Model entity operations. + /// Creates a new instance of the repository. /// - public class ModelRepository : IModelRepository + /// The database context factory + /// The logger + public ModelRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly IDbContextFactory _dbContextFactory; + } - public ModelRepository(IDbContextFactory dbContextFactory) - { - _dbContextFactory = dbContextFactory; - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.Models; - public async Task GetByIdAsync(int id) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .FirstOrDefaultAsync(m => m.Id == id); - } + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + { + return query + .Include(m => m.Series) + .Include(m => m.Identifiers); + } - public async Task GetByIdWithDetailsAsync(int id) + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderBy(m => m.Name); + } + + /// + /// Applies includes for Series (with Author) and Identifiers โ€” the full detail set. + /// + private static IQueryable ApplyDetailIncludes(IQueryable query) + { + return query + .Include(m => m.Series) + .ThenInclude(s => s.Author) + .Include(m => m.Identifiers); + } + + /// + public async Task GetByIdWithDetailsAsync(int id, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .Include(m => m.Series) - .ThenInclude(s => s.Author) - .Include(m => m.Identifiers) - .FirstOrDefaultAsync(m => m.Id == id); - } + return await ApplyDetailIncludes(GetDbSet(context)) + .AsNoTracking() + .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); + }, cancellationToken, $"getting with details for ID {id}"); + } - public async Task> GetAllAsync() + /// + public async Task> GetAllWithDetailsAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() + return await ApplyDetailIncludes(GetDbSet(context)) + .AsNoTracking() .OrderBy(m => m.Name) - .ToListAsync(); - } + .ToListAsync(cancellationToken); + }, cancellationToken, "getting all with details"); + } - public async Task> GetAllWithDetailsAsync() + /// + public async Task<(List Items, int TotalCount)> GetPaginatedWithFilterAsync( + int? page = null, + int? pageSize = null, + string? search = null, + string? capability = null, + bool? hasProviders = null, + CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .Include(m => m.Series) - .ThenInclude(s => s.Author) - .OrderBy(m => m.Name) - .ToListAsync(); + var query = ApplyDetailIncludes(GetDbSet(context)) + .AsNoTracking() + .AsQueryable(); + + // Apply search filter + if (!string.IsNullOrWhiteSpace(search)) + { + var lowerSearch = search.ToLower(); + query = query.Where(m => m.Name.ToLower().Contains(lowerSearch)); + } + + // Apply capability filter + if (!string.IsNullOrWhiteSpace(capability)) + { + query = capability.ToLower() switch + { + "chat" => query.Where(m => m.SupportsChat), + "vision" => query.Where(m => m.SupportsVision), + "image" => query.Where(m => m.SupportsImageGeneration), + "video" => query.Where(m => m.SupportsVideoGeneration), + "embeddings" => query.Where(m => m.SupportsEmbeddings), + _ => query + }; + } + + // Apply provider filter + if (hasProviders.HasValue) + { + query = hasProviders.Value + ? query.Where(m => m.Identifiers.Any()) + : query.Where(m => !m.Identifiers.Any()); + } + + var totalCount = await query.CountAsync(cancellationToken); + + query = query.OrderBy(m => m.Name); + + // Apply pagination if requested + if (page.HasValue && pageSize.HasValue) + { + query = query + .Skip((page.Value - 1) * pageSize.Value) + .Take(pageSize.Value); + } + + var items = await query.ToListAsync(cancellationToken); + return (items, totalCount); + }, cancellationToken, "getting paginated models with filter"); + } + + /// + public async Task GetByIdentifierAsync(string identifier, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(identifier)) + { + return null; } - public async Task GetByIdentifierAsync(string identifier) + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - // First check ModelIdentifiers table + // First check ModelProviderTypeAssociation table var modelIdentifier = await context.Set() .Include(mi => mi.Model) .ThenInclude(m => m.Series) + .AsNoTracking() .Where(mi => mi.Identifier == identifier) .OrderBy(mi => mi.IsPrimary ? 0 : 1) // Prefer primary identifier - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); if (modelIdentifier != null) + { return modelIdentifier.Model; + } // Fallback: Check by model name - return await context.Set() + return await GetDbSet(context) .Include(m => m.Series) - .FirstOrDefaultAsync(m => m.Name == identifier); - } + .AsNoTracking() + .FirstOrDefaultAsync(m => m.Name == identifier, cancellationToken); + }, cancellationToken, $"getting by identifier {identifier}"); + } - public async Task> GetBySeriesAsync(int seriesId) + /// + public async Task> GetBySeriesAsync(int seriesId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() + return await GetDbSet(context) + .AsNoTracking() .Where(m => m.ModelSeriesId == seriesId) .OrderBy(m => m.Name) - .ToListAsync(); - } - - public async Task CreateAsync(Model model) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - context.Set().Add(model); - await context.SaveChangesAsync(); - return model; - } + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting by series ID {seriesId}"); + } - public async Task UpdateAsync(Model model) + /// + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(name)) { - using var context = await _dbContextFactory.CreateDbContextAsync(); - context.Set().Update(model); - await context.SaveChangesAsync(); - return model; + return null; } - public async Task ExistsAsync(int id) + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .AnyAsync(m => m.Id == id); - } + return await GetDbSet(context) + .AsNoTracking() + .FirstOrDefaultAsync(m => m.Name == name, cancellationToken); + }, cancellationToken, $"getting by name {name}"); + } - public async Task GetByNameAsync(string name) + /// + public async Task> SearchByNameAsync(string query, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(query)) { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .FirstOrDefaultAsync(m => m.Name == name); + return new List(); } - public async Task> SearchByNameAsync(string query) + var lowerQuery = query.ToLower(); + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - var lowerQuery = query.ToLower(); - return await context.Set() + return await GetDbSet(context) + .AsNoTracking() .Where(m => m.Name.ToLower().Contains(lowerQuery) && m.IsActive) .OrderBy(m => m.Name) - .ToListAsync(); - } + .ToListAsync(cancellationToken); + }, cancellationToken, $"searching by name {query}"); + } - public async Task HasMappingReferencesAsync(int modelId) + /// + public async Task HasMappingReferencesAsync(int modelId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Set() .Include(m => m.ModelProviderTypeAssociation) - .AnyAsync(m => m.ModelProviderTypeAssociation != null && m.ModelProviderTypeAssociation.ModelId == modelId); - } - - public async Task DeleteAsync(int id) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - var model = await context.Set().FindAsync(id); - if (model == null) - return false; - - context.Set().Remove(model); - await context.SaveChangesAsync(); - return true; - } + .AnyAsync(m => m.ModelProviderTypeAssociation != null && m.ModelProviderTypeAssociation.ModelId == modelId, cancellationToken); + }, cancellationToken, $"checking mapping references for ID {modelId}"); + } - public async Task> GetByProviderAsync(ProviderType providerType) + /// + public async Task> GetByProviderAsync(ProviderType providerType, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - // Get model IDs that have identifiers for this provider var modelIds = await context.Set() + .AsNoTracking() .Where(mi => mi.Provider == providerType) .Select(mi => mi.ModelId) .Distinct() - .ToListAsync(); + .ToListAsync(cancellationToken); - // Return models with those IDs, including capabilities and identifiers - return await context.Set() - .Include(m => m.Series) - .ThenInclude(s => s.Author) - .Include(m => m.Identifiers) + // Return models with those IDs, including series, author, and identifiers + return await ApplyDetailIncludes(GetDbSet(context)) + .AsNoTracking() .Where(m => modelIds.Contains(m.Id)) .OrderBy(m => m.Name) - .ToListAsync(); - } + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting by provider {providerType}"); + } - public async Task DeleteIdentifierAsync(int modelId, int identifierId) + /// + public async Task DeleteIdentifierAsync(int modelId, int identifierId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - var identifier = await context.Set() - .FirstOrDefaultAsync(i => i.Id == identifierId && i.ModelId == modelId); - + .FirstOrDefaultAsync(i => i.Id == identifierId && i.ModelId == modelId, cancellationToken); + if (identifier == null) { return false; } - + context.Set().Remove(identifier); - await context.SaveChangesAsync(); - - return true; + int rowsAffected = await context.SaveChangesAsync(cancellationToken); + return rowsAffected > 0; + }, cancellationToken, $"deleting identifier {identifierId} for model {modelId}"); + } + + /// + public async Task CreateModelAsync(Model model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + try + { + return await ExecuteAsync(async context => + { + OnBeforeCreate(model); + GetDbSet(context).Add(model); + await context.SaveChangesAsync(cancellationToken); + return model; + }, cancellationToken); + } + catch (DbUpdateException ex) + { + Logger.LogError(ex, "Database error creating {EntityType}", EntityTypeName); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error creating {EntityType}", EntityTypeName); + throw; + } + } + + /// + public async Task UpdateModelAsync(Model model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + try + { + return await ExecuteAsync(async context => + { + OnBeforeUpdate(model); + GetDbSet(context).Update(model); + await context.SaveChangesAsync(cancellationToken); + return model; + }, cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + Logger.LogError(ex, "Concurrency error updating {EntityType} with ID {Id}", EntityTypeName, model.Id); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error updating {EntityType} with ID {Id}", EntityTypeName, model.Id); + throw; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/ModelSeriesRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/ModelSeriesRepository.cs index e69c27bcb..41447417a 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/ModelSeriesRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/ModelSeriesRepository.cs @@ -1,103 +1,150 @@ using ConduitLLM.Configuration.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for ModelSeries entity operations. +/// Inherits common CRUD operations from RepositoryBase. +/// +public class ModelSeriesRepository : RepositoryBase, IModelSeriesRepository { /// - /// Repository for ModelSeries entity operations. + /// Creates a new instance of the repository. /// - public class ModelSeriesRepository : IModelSeriesRepository + /// The database context factory + /// The logger + public ModelSeriesRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly IDbContextFactory _dbContextFactory; + } - public ModelSeriesRepository(IDbContextFactory dbContextFactory) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.ModelSeries; - public async Task GetByIdAsync(int id) - { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .FirstOrDefaultAsync(s => s.Id == id); - } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderBy(s => s.Name); + } - public async Task GetByIdWithAuthorAsync(int id) + /// + public async Task GetByIdWithAuthorAsync(int id, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() + return await GetDbSet(context) .Include(s => s.Author) - .FirstOrDefaultAsync(s => s.Id == id); - } + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + }, cancellationToken, $"getting with author for ID {id}"); + } - public async Task> GetAllAsync() + /// + public async Task> GetAllWithAuthorAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() + return await GetDbSet(context) + .Include(s => s.Author) + .AsNoTracking() .OrderBy(s => s.Name) - .ToListAsync(); - } + .ToListAsync(cancellationToken); + }, cancellationToken, "getting all with author"); + } - public async Task> GetAllWithAuthorAsync() + /// + public async Task GetByNameAndAuthorAsync(string name, int authorId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(name)) { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .Include(s => s.Author) - .OrderBy(s => s.Name) - .ToListAsync(); + return null; } - public async Task GetByNameAndAuthorAsync(string name, int authorId) + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - return await context.Set() - .FirstOrDefaultAsync(s => s.Name == name && s.AuthorId == authorId); - } + return await GetDbSet(context) + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Name == name && s.AuthorId == authorId, cancellationToken); + }, cancellationToken, $"getting by name {name} and author ID {authorId}"); + } - public async Task?> GetModelsInSeriesAsync(int seriesId) + /// + public async Task?> GetModelsInSeriesAsync(int seriesId, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var context = await _dbContextFactory.CreateDbContextAsync(); - var exists = await context.Set() - .AnyAsync(s => s.Id == seriesId); - + var exists = await GetDbSet(context) + .AnyAsync(s => s.Id == seriesId, cancellationToken); + if (!exists) + { return null; + } - return await context.Set() + return await context.Models + .AsNoTracking() .Where(m => m.ModelSeriesId == seriesId) .OrderBy(m => m.Name) - .ToListAsync(); - } + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting models for series ID {seriesId}"); + } + + /// + public async Task CreateSeriesAsync(ModelSeries series, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(series); - public async Task CreateAsync(ModelSeries series) + try { - using var context = await _dbContextFactory.CreateDbContextAsync(); - context.Set().Add(series); - await context.SaveChangesAsync(); - return series; + return await ExecuteAsync(async context => + { + OnBeforeCreate(series); + GetDbSet(context).Add(series); + await context.SaveChangesAsync(cancellationToken); + return series; + }, cancellationToken); } - - public async Task UpdateAsync(ModelSeries series) + catch (DbUpdateException ex) { - using var context = await _dbContextFactory.CreateDbContextAsync(); - context.Set().Update(series); - await context.SaveChangesAsync(); - return series; + Logger.LogError(ex, "Database error creating {EntityType}", EntityTypeName); + throw; } + catch (Exception ex) + { + Logger.LogError(ex, "Error creating {EntityType}", EntityTypeName); + throw; + } + } + + /// + public async Task UpdateSeriesAsync(ModelSeries series, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(series); - public async Task DeleteAsync(int id) + try + { + return await ExecuteAsync(async context => + { + OnBeforeUpdate(series); + GetDbSet(context).Update(series); + await context.SaveChangesAsync(cancellationToken); + return series; + }, cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + Logger.LogError(ex, "Concurrency error updating {EntityType} with ID {Id}", EntityTypeName, series.Id); + throw; + } + catch (Exception ex) { - using var context = await _dbContextFactory.CreateDbContextAsync(); - var series = await context.Set() - .FirstOrDefaultAsync(s => s.Id == id); - - if (series == null) - return false; - - context.Set().Remove(series); - await context.SaveChangesAsync(); - return true; + Logger.LogError(ex, "Error updating {EntityType} with ID {Id}", EntityTypeName, series.Id); + throw; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/NotificationRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/NotificationRepository.cs index 413702764..f64bcb718 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/NotificationRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/NotificationRepository.cs @@ -1,240 +1,199 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for notifications using Entity Framework Core. +/// Inherits common CRUD operations from RepositoryBase. +/// +public class NotificationRepository : RepositoryBase, INotificationRepository { /// - /// Repository implementation for notifications using Entity Framework Core + /// Creates a new instance of the repository. /// - public class NotificationRepository : INotificationRepository + /// The database context factory + /// The logger + public NotificationRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - - /// - /// Creates a new instance of the repository - /// - /// The database context factory - /// The logger - public NotificationRepository( - IDbContextFactory dbContextFactory, - ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + } - /// - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.Notifications - .AsNoTracking() - .FirstOrDefaultAsync(n => n.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting notification with ID {NotificationId}", id); - throw; - } - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.Notifications; - /// - public async Task> GetAllAsync(CancellationToken cancellationToken = default) + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderByDescending(n => n.CreatedAt); + } + + /// + /// Override to set CreatedAt for Notification (which only has CreatedAt, not UpdatedAt). + /// + protected override void OnBeforeCreate(Notification entity) + { + entity.CreatedAt = DateTime.UtcNow; + } + + /// + public async Task<(List Items, int TotalCount)> GetUnreadPaginatedAsync( + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) + { + return await GetFilteredPaginatedAsync( + n => !n.IsRead, + pageNumber, + pageSize, + q => q.OrderByDescending(n => n.CreatedAt), + cancellationToken, + "getting unread notifications"); + } + + /// + public async Task> GetUnreadByVirtualKeyIdAsync( + int virtualKeyId, + CancellationToken cancellationToken = default) + { + try { - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.Notifications + return await GetDbSet(context) .AsNoTracking() + .Where(n => !n.IsRead && n.VirtualKeyId == virtualKeyId) .OrderByDescending(n => n.CreatedAt) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all notifications"); - throw; - } + }, cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting unread notifications for virtual key {VirtualKeyId}", virtualKeyId); + throw; } + } - /// - public async Task> GetUnreadAsync(CancellationToken cancellationToken = default) + /// + public async Task> GetUnreadByVirtualKeyAndTypeAsync( + int virtualKeyId, + NotificationType notificationType, + CancellationToken cancellationToken = default) + { + try { - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.Notifications + return await GetDbSet(context) .AsNoTracking() - .Where(n => !n.IsRead) + .Where(n => !n.IsRead && n.VirtualKeyId == virtualKeyId && n.Type == notificationType) .OrderByDescending(n => n.CreatedAt) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting unread notifications"); - throw; - } + }, cancellationToken); } - - /// - public async Task CreateAsync(Notification notification, CancellationToken cancellationToken = default) + catch (Exception ex) { - if (notification == null) - { - throw new ArgumentNullException(nameof(notification)); - } + Logger.LogError(ex, "Error getting unread notifications for virtual key {VirtualKeyId} and type {NotificationType}", + virtualKeyId, notificationType); + throw; + } + } - try + /// + public async Task MarkAsReadAsync(int id, CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var notification = await GetDbSet(context).FindAsync(new object[] { id }, cancellationToken); - // Set created timestamp - notification.CreatedAt = DateTime.UtcNow; + if (notification == null) + { + return false; + } - dbContext.Notifications.Add(notification); - await dbContext.SaveChangesAsync(cancellationToken); - return notification.Id; - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating notification '{NotificationType}'", notification.Type); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating notification '{NotificationType}'", notification.Type); - throw; - } + notification.IsRead = true; + int rowsAffected = await context.SaveChangesAsync(cancellationToken); + return rowsAffected > 0; + }, cancellationToken); } - - /// - public async Task UpdateAsync(Notification notification, CancellationToken cancellationToken = default) + catch (DbUpdateConcurrencyException ex) { - if (notification == null) - { - throw new ArgumentNullException(nameof(notification)); - } + Logger.LogError(ex, "Concurrency error marking notification with ID {NotificationId} as read", id); + // Handle concurrency issues by retrying try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Ensure the entity is tracked - dbContext.Notifications.Update(notification); - - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (DbUpdateConcurrencyException ex) - { - _logger.LogError(ex, "Concurrency error updating notification with ID {NotificationId}", notification.Id); - - // Handle concurrency issues by reloading and reapplying changes if needed - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var existingEntity = await dbContext.Notifications.FindAsync(new object[] { notification.Id }, cancellationToken); + var notification = await GetDbSet(context).FindAsync(new object[] { id }, cancellationToken); - if (existingEntity == null) + if (notification == null) { return false; } - // Update properties - dbContext.Entry(existingEntity).CurrentValues.SetValues(notification); - - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); + notification.IsRead = true; + int rowsAffected = await context.SaveChangesAsync(cancellationToken); return rowsAffected > 0; - } - catch (Exception retryEx) - { - _logger.LogError(retryEx, "Error during retry of notification update with ID {NotificationId}", notification.Id); - throw; - } + }, cancellationToken); } - catch (Exception ex) + catch (Exception retryEx) { - _logger.LogError(ex, "Error updating notification with ID {NotificationId}", notification.Id); + Logger.LogError(retryEx, "Error during retry of marking notification with ID {NotificationId} as read", id); throw; } } - - /// - public async Task MarkAsReadAsync(int id, CancellationToken cancellationToken = default) + catch (Exception ex) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var notification = await dbContext.Notifications.FindAsync(new object[] { id }, cancellationToken); + Logger.LogError(ex, "Error marking notification with ID {NotificationId} as read", id); + throw; + } + } - if (notification == null) - { - return false; - } + /// + /// Override UpdateAsync to include concurrency retry logic. + /// + public override async Task UpdateAsync(Notification entity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entity); - notification.IsRead = true; + try + { + return await base.UpdateAsync(entity, cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + Logger.LogError(ex, "Concurrency error updating notification with ID {NotificationId}", entity.Id); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (DbUpdateConcurrencyException ex) + // Handle concurrency issues by reloading and reapplying changes + try { - _logger.LogError(ex, "Concurrency error marking notification with ID {NotificationId} as read", id); - - // Handle concurrency issues by retrying - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var notification = await dbContext.Notifications.FindAsync(new object[] { id }, cancellationToken); + var existingEntity = await GetDbSet(context).FindAsync(new object[] { entity.Id }, cancellationToken); - if (notification == null) + if (existingEntity == null) { return false; } - notification.IsRead = true; + // Update properties + context.Entry(existingEntity).CurrentValues.SetValues(entity); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); + int rowsAffected = await context.SaveChangesAsync(cancellationToken); return rowsAffected > 0; - } - catch (Exception retryEx) - { - _logger.LogError(retryEx, "Error during retry of marking notification with ID {NotificationId} as read", id); - throw; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error marking notification with ID {NotificationId} as read", id); - throw; - } - } - - /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var notification = await dbContext.Notifications.FindAsync(new object[] { id }, cancellationToken); - - if (notification == null) - { - return false; - } - - dbContext.Notifications.Remove(notification); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; + }, cancellationToken); } - catch (Exception ex) + catch (Exception retryEx) { - _logger.LogError(ex, "Error deleting notification with ID {NotificationId}", id); + Logger.LogError(retryEx, "Error during retry of notification update with ID {NotificationId}", entity.Id); throw; } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/ProviderKeyCredentialRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/ProviderKeyCredentialRepository.cs index 814f2d6a1..cb6659fb2 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/ProviderKeyCredentialRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/ProviderKeyCredentialRepository.cs @@ -1,220 +1,365 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; + using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for ProviderKeyCredential operations. +/// Extends RepositoryBase for standard CRUD operations and implements domain-specific methods. +/// +public class ProviderKeyCredentialRepository : RepositoryBase, IProviderKeyCredentialRepository { /// - /// Repository implementation for ProviderKeyCredential operations + /// Creates a new instance of the repository. /// - public class ProviderKeyCredentialRepository : IProviderKeyCredentialRepository + /// The database context factory + /// The logger + public ProviderKeyCredentialRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly ConduitDbContext _context; - private readonly ILogger _logger; + } - public ProviderKeyCredentialRepository( - ConduitDbContext context, - ILogger logger) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.ProviderKeyCredentials; - public async Task> GetAllAsync() - { - return await _context.ProviderKeyCredentials - .Include(k => k.Provider) - .OrderBy(k => k.ProviderId) - .ThenByDescending(k => k.IsPrimary) - .ThenBy(k => k.ProviderAccountGroup) - .ToListAsync(); - } + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + { + return query.Include(c => c.Provider); + } - public async Task> GetByProviderIdAsync(int ProviderId) - { - return await _context.ProviderKeyCredentials - .Where(k => k.ProviderId == ProviderId) - .OrderByDescending(k => k.IsPrimary) - .ThenBy(k => k.ProviderAccountGroup) - .ToListAsync(); - } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query + .OrderBy(k => k.ProviderId) + .ThenByDescending(k => k.IsPrimary) + .ThenBy(k => k.ProviderAccountGroup); + } + + /// + public async Task<(List Items, int TotalCount)> GetByProviderIdPaginatedAsync( + int providerId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) + { + return await GetFilteredPaginatedAsync( + k => k.ProviderId == providerId, + pageNumber, + pageSize, + q => q.OrderByDescending(k => k.IsPrimary).ThenBy(k => k.ProviderAccountGroup), + cancellationToken, + $"getting paginated credentials for provider {providerId}"); + } - public async Task GetByIdAsync(int id) + /// + public async Task GetPrimaryKeyAsync(int providerId) + { + try { - return await _context.ProviderKeyCredentials - .Include(k => k.Provider) - .FirstOrDefaultAsync(k => k.Id == id); + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .FirstOrDefaultAsync(k => k.ProviderId == providerId + && k.IsPrimary + && k.IsEnabled)); } - - public async Task GetPrimaryKeyAsync(int ProviderId) + catch (Exception ex) { - return await _context.ProviderKeyCredentials - .FirstOrDefaultAsync(k => k.ProviderId == ProviderId - && k.IsPrimary - && k.IsEnabled); + Logger.LogError(ex, "Error getting primary key for provider {ProviderId}", providerId); + throw; } + } - public async Task> GetEnabledKeysByProviderIdAsync(int ProviderId) + /// + public async Task> GetEnabledKeysByProviderIdAsync(int providerId) + { + try { - return await _context.ProviderKeyCredentials - .Where(k => k.ProviderId == ProviderId && k.IsEnabled) - .OrderByDescending(k => k.IsPrimary) - .ThenBy(k => k.ProviderAccountGroup) - .ToListAsync(); + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Where(k => k.ProviderId == providerId && k.IsEnabled) + .OrderByDescending(k => k.IsPrimary) + .ThenBy(k => k.ProviderAccountGroup) + .ToListAsync()); } - - public async Task CreateAsync(ProviderKeyCredential keyCredential) + catch (Exception ex) { - ArgumentNullException.ThrowIfNull(keyCredential); + Logger.LogError(ex, "Error getting enabled keys for provider {ProviderId}", providerId); + throw; + } + } - keyCredential.CreatedAt = DateTime.UtcNow; - keyCredential.UpdatedAt = DateTime.UtcNow; + /// + /// Creates a new key credential with automatic primary key assignment. + /// If this is the only enabled key for the provider, it will be automatically set as primary. + /// + public override async Task CreateAsync(ProviderKeyCredential entity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entity); - // Check if this should be automatically set as primary - if (keyCredential.IsEnabled && !keyCredential.IsPrimary) + try + { + return await ExecuteAsync(async context => { - var enabledKeysCount = await _context.ProviderKeyCredentials - .CountAsync(k => k.ProviderId == keyCredential.ProviderId && k.IsEnabled); + OnBeforeCreate(entity); - // If this will be the only enabled key, set it as primary - if (enabledKeysCount == 0) + if (entity.IsEnabled && entity.IsPrimary) + { + // Demote existing primary key so the new one can take over + var existingPrimary = await GetDbSet(context) + .FirstOrDefaultAsync(k => k.ProviderId == entity.ProviderId && k.IsPrimary, cancellationToken); + + if (existingPrimary != null) + { + existingPrimary.IsPrimary = false; + existingPrimary.UpdatedAt = DateTime.UtcNow; + Logger.LogInformation("Demoted existing primary key {KeyId} for provider {ProviderId}", + existingPrimary.Id, entity.ProviderId); + } + } + else if (entity.IsEnabled && !entity.IsPrimary) { - keyCredential.IsPrimary = true; - _logger.LogInformation("Automatically setting key as primary since it's the only enabled key for provider {ProviderId}", - keyCredential.ProviderId); + // Check if this should be automatically set as primary + var enabledKeysCount = await GetDbSet(context) + .CountAsync(k => k.ProviderId == entity.ProviderId && k.IsEnabled, cancellationToken); + + // If this will be the only enabled key, set it as primary + if (enabledKeysCount == 0) + { + entity.IsPrimary = true; + Logger.LogInformation("Automatically setting key as primary since it's the only enabled key for provider {ProviderId}", + entity.ProviderId); + } } - } - _context.ProviderKeyCredentials.Add(keyCredential); - await _context.SaveChangesAsync(); + GetDbSet(context).Add(entity); + await context.SaveChangesAsync(cancellationToken); - _logger.LogInformation("Created key credential {KeyId} for provider {ProviderId} (IsPrimary: {IsPrimary})", - keyCredential.Id, keyCredential.ProviderId, keyCredential.IsPrimary); + Logger.LogInformation("Created key credential {KeyId} for provider {ProviderId} (IsPrimary: {IsPrimary})", + entity.Id, entity.ProviderId, entity.IsPrimary); - return keyCredential; + return entity.Id; + }, cancellationToken); } - - public async Task UpdateAsync(ProviderKeyCredential keyCredential) + catch (DbUpdateException ex) + { + Logger.LogError(ex, "Database error creating key credential for provider {ProviderId}", entity.ProviderId); + throw; + } + catch (Exception ex) { - ArgumentNullException.ThrowIfNull(keyCredential); + Logger.LogError(ex, "Error creating key credential for provider {ProviderId}", entity.ProviderId); + throw; + } + } - var existingKey = await _context.ProviderKeyCredentials - .FirstOrDefaultAsync(k => k.Id == keyCredential.Id); + /// + /// Updates an existing key credential with automatic primary key assignment. + /// If this becomes the only enabled key when being enabled, it will be automatically set as primary. + /// + public override async Task UpdateAsync(ProviderKeyCredential entity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entity); - if (existingKey == null) - return false; + try + { + return await ExecuteAsync(async context => + { + var existingKey = await GetDbSet(context) + .FirstOrDefaultAsync(k => k.Id == entity.Id, cancellationToken); - bool wasEnabled = existingKey.IsEnabled; - bool willBeEnabled = keyCredential.IsEnabled; + if (existingKey == null) + return false; - // Update properties - existingKey.ProviderAccountGroup = keyCredential.ProviderAccountGroup; - existingKey.ApiKey = keyCredential.ApiKey; - existingKey.BaseUrl = keyCredential.BaseUrl; - existingKey.IsPrimary = keyCredential.IsPrimary; - existingKey.IsEnabled = keyCredential.IsEnabled; - existingKey.UpdatedAt = DateTime.UtcNow; + bool wasEnabled = existingKey.IsEnabled; + bool willBeEnabled = entity.IsEnabled; - // Check if this should be automatically set as primary when being enabled - if (!wasEnabled && willBeEnabled && !keyCredential.IsPrimary) - { - var enabledKeysCount = await _context.ProviderKeyCredentials - .CountAsync(k => k.ProviderId == existingKey.ProviderId && k.IsEnabled && k.Id != existingKey.Id); + // Update properties + existingKey.ProviderAccountGroup = entity.ProviderAccountGroup; + existingKey.ApiKey = entity.ApiKey; + existingKey.BaseUrl = entity.BaseUrl; + existingKey.IsPrimary = entity.IsPrimary; + existingKey.IsEnabled = entity.IsEnabled; + existingKey.UpdatedAt = DateTime.UtcNow; - // If this will be the only enabled key, set it as primary - if (enabledKeysCount == 0) + if (entity.IsPrimary && entity.IsEnabled) + { + // Demote existing primary key so this one can become primary + var otherPrimary = await GetDbSet(context) + .FirstOrDefaultAsync(k => k.ProviderId == existingKey.ProviderId && k.IsPrimary && k.Id != existingKey.Id, cancellationToken); + + if (otherPrimary != null) + { + otherPrimary.IsPrimary = false; + otherPrimary.UpdatedAt = DateTime.UtcNow; + Logger.LogInformation("Demoted existing primary key {KeyId} for provider {ProviderId}", + otherPrimary.Id, existingKey.ProviderId); + } + } + else if (!wasEnabled && willBeEnabled && !entity.IsPrimary) { - existingKey.IsPrimary = true; - _logger.LogInformation("Automatically setting key {KeyId} as primary since it's the only enabled key for provider {ProviderId}", - existingKey.Id, existingKey.ProviderId); + // Check if this should be automatically set as primary when being enabled + var enabledKeysCount = await GetDbSet(context) + .CountAsync(k => k.ProviderId == existingKey.ProviderId && k.IsEnabled && k.Id != existingKey.Id, cancellationToken); + + // If this will be the only enabled key, set it as primary + if (enabledKeysCount == 0) + { + existingKey.IsPrimary = true; + Logger.LogInformation("Automatically setting key {KeyId} as primary since it's the only enabled key for provider {ProviderId}", + existingKey.Id, existingKey.ProviderId); + } } - } - await _context.SaveChangesAsync(); + await context.SaveChangesAsync(cancellationToken); - _logger.LogInformation("Updated key credential {KeyId} for provider {ProviderId} (IsPrimary: {IsPrimary})", - keyCredential.Id, keyCredential.ProviderId, existingKey.IsPrimary); + Logger.LogInformation("Updated key credential {KeyId} for provider {ProviderId} (IsPrimary: {IsPrimary})", + entity.Id, entity.ProviderId, existingKey.IsPrimary); - return true; + return true; + }, cancellationToken); } + catch (DbUpdateConcurrencyException ex) + { + Logger.LogError(ex, "Concurrency error updating key credential {KeyId}", entity.Id); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error updating key credential {KeyId}", entity.Id); + throw; + } + } - public async Task DeleteAsync(int id) + /// + /// Deletes a key credential by ID. + /// + public override async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + try { - var keyCredential = await _context.ProviderKeyCredentials - .FirstOrDefaultAsync(k => k.Id == id); + return await ExecuteAsync(async context => + { + var keyCredential = await GetDbSet(context) + .FirstOrDefaultAsync(k => k.Id == id, cancellationToken); - if (keyCredential == null) - return false; + if (keyCredential == null) + return false; - _context.ProviderKeyCredentials.Remove(keyCredential); - await _context.SaveChangesAsync(); + GetDbSet(context).Remove(keyCredential); + await context.SaveChangesAsync(cancellationToken); - _logger.LogInformation("Deleted key credential {KeyId} for provider {ProviderId}", - id, keyCredential.ProviderId); + Logger.LogInformation("Deleted key credential {KeyId} for provider {ProviderId}", + id, keyCredential.ProviderId); - return true; + return true; + }, cancellationToken); } + catch (Exception ex) + { + Logger.LogError(ex, "Error deleting key credential {KeyId}", id); + throw; + } + } - public async Task SetPrimaryKeyAsync(int ProviderId, int keyId) + /// + public async Task SetPrimaryKeyAsync(int providerId, int keyId) + { + try { - using var transaction = await (_context as DbContext)!.Database.BeginTransactionAsync(); - try + return await ExecuteAsync(async context => { - // First, unset any existing primary keys - var existingPrimaryKeys = await _context.ProviderKeyCredentials - .Where(k => k.ProviderId == ProviderId && k.IsPrimary) - .ToListAsync(); - - foreach (var key in existingPrimaryKeys) + using var transaction = await context.Database.BeginTransactionAsync(); + try { - key.IsPrimary = false; - key.UpdatedAt = DateTime.UtcNow; - } + // First, unset any existing primary keys + var existingPrimaryKeys = await GetDbSet(context) + .Where(k => k.ProviderId == providerId && k.IsPrimary) + .ToListAsync(); - // Save changes to unset primary keys first to avoid constraint violation - if (existingPrimaryKeys.Count() > 0) - { - await _context.SaveChangesAsync(); - } + foreach (var key in existingPrimaryKeys) + { + key.IsPrimary = false; + key.UpdatedAt = DateTime.UtcNow; + } - // Set the new primary key - var newPrimaryKey = await _context.ProviderKeyCredentials - .FirstOrDefaultAsync(k => k.Id == keyId && k.ProviderId == ProviderId); + // Save changes to unset primary keys first to avoid constraint violation + if (existingPrimaryKeys.Count > 0) + { + await context.SaveChangesAsync(); + } - if (newPrimaryKey == null) - return false; + // Set the new primary key + var newPrimaryKey = await GetDbSet(context) + .FirstOrDefaultAsync(k => k.Id == keyId && k.ProviderId == providerId); - newPrimaryKey.IsPrimary = true; - newPrimaryKey.UpdatedAt = DateTime.UtcNow; + if (newPrimaryKey == null) + return false; - await _context.SaveChangesAsync(); - await transaction.CommitAsync(); + newPrimaryKey.IsPrimary = true; + newPrimaryKey.UpdatedAt = DateTime.UtcNow; - _logger.LogInformation("Set key {KeyId} as primary for provider {ProviderId}", - keyId, ProviderId); + await context.SaveChangesAsync(); + await transaction.CommitAsync(); - return true; - } - catch (Exception ex) - { - await transaction.RollbackAsync(); - _logger.LogError(ex, "Failed to set primary key {KeyId} for provider {ProviderId}", - keyId, ProviderId); - throw; - } + Logger.LogInformation("Set key {KeyId} as primary for provider {ProviderId}", + keyId, providerId); + + return true; + } + catch (Exception) + { + await transaction.RollbackAsync(); + throw; + } + }); } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to set primary key {KeyId} for provider {ProviderId}", + keyId, providerId); + throw; + } + } - public async Task HasKeyCredentialsAsync(int ProviderId) + /// + public async Task HasKeyCredentialsAsync(int providerId) + { + try + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AnyAsync(k => k.ProviderId == providerId)); + } + catch (Exception ex) { - return await _context.ProviderKeyCredentials - .AnyAsync(k => k.ProviderId == ProviderId); + Logger.LogError(ex, "Error checking if provider {ProviderId} has key credentials", providerId); + throw; } + } - public async Task CountByProviderIdAsync(int ProviderId) + /// + public async Task CountByProviderIdAsync(int providerId) + { + try + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .CountAsync(k => k.ProviderId == providerId)); + } + catch (Exception ex) { - return await _context.ProviderKeyCredentials - .CountAsync(k => k.ProviderId == ProviderId); + Logger.LogError(ex, "Error counting key credentials for provider {ProviderId}", providerId); + throw; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/ProviderRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/ProviderRepository.cs index d5a0ed661..da55f6f97 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/ProviderRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/ProviderRepository.cs @@ -1,169 +1,69 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Utilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for providers using Entity Framework Core. +/// Inherits common CRUD operations from RepositoryBase. +/// +public class ProviderRepository : RepositoryBase, IProviderRepository { /// - /// Repository implementation for providers using Entity Framework Core + /// Creates a new instance of the repository. /// - public class ProviderRepository : IProviderRepository + /// The database context factory + /// The logger + public ProviderRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; + } - /// - /// Creates a new instance of the repository - /// - /// The database context factory - /// The logger - public ProviderRepository( - IDbContextFactory dbContextFactory, - ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.Providers - .Include(pc => pc.ProviderKeyCredentials) - .AsNoTracking() - .FirstOrDefaultAsync(pc => pc.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting provider with ID {ProviderId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) => context.Providers; + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + { + return query.Include(p => p.ProviderKeyCredentials); + } - /// - public async Task> GetAllAsync(CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.Providers - .Include(pc => pc.ProviderKeyCredentials) - .AsNoTracking() - .OrderBy(pc => pc.ProviderType) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all providers"); - throw; - } - } + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderBy(p => p.ProviderType); + } - /// - public async Task CreateAsync(Provider provider, CancellationToken cancellationToken = default) + /// + public async Task> GetProviderNameMapAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - if (provider == null) - { - throw new ArgumentNullException(nameof(provider)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Set created/updated timestamps - if (provider.CreatedAt == default) - { - provider.CreatedAt = DateTime.UtcNow; - } - - provider.UpdatedAt = DateTime.UtcNow; - - dbContext.Providers.Add(provider); - await dbContext.SaveChangesAsync(cancellationToken); - return provider.Id; - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating provider for provider '{ProviderType}'", - LogSanitizer.SanitizeObject(provider.ProviderType)); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating provider for provider '{ProviderType}'", - LogSanitizer.SanitizeObject(provider.ProviderType)); - throw; - } - } + return await GetDbSet(context) + .AsNoTracking() + .ToDictionaryAsync(p => p.Id, p => p.ProviderName ?? p.ProviderType.ToString(), cancellationToken); + }, cancellationToken, "getting provider name map"); + } - /// - public async Task UpdateAsync(Provider provider, CancellationToken cancellationToken = default) + /// + public async Task CountAsync(bool? enabledOnly, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - if (provider == null) - { - throw new ArgumentNullException(nameof(provider)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var query = GetDbSet(context).AsNoTracking(); - // Ensure the entity is tracked - dbContext.Providers.Update(provider); - - // Set the updated timestamp - provider.UpdatedAt = DateTime.UtcNow; - - // Save changes - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (DbUpdateConcurrencyException ex) + if (enabledOnly.HasValue) { - _logger.LogError(ex, "Concurrency error updating provider with ID {ProviderId}", - LogSanitizer.SanitizeObject(provider.Id)); - - // Additional handling for concurrency issues could be implemented here - throw; + query = query.Where(p => p.IsEnabled == enabledOnly.Value); } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating provider with ID {ProviderId}", - LogSanitizer.SanitizeObject(provider.Id)); - throw; - } - } - /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var provider = await dbContext.Providers.FindAsync(new object[] { id }, cancellationToken); - - if (provider == null) - { - return false; - } - - dbContext.Providers.Remove(provider); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting provider with ID {ProviderId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } + return await query.CountAsync(cancellationToken); + }, cancellationToken, $"counting (enabledOnly: {enabledOnly})"); } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/RepositoryBase.cs b/Shared/ConduitLLM.Configuration/Repositories/RepositoryBase.cs new file mode 100644 index 000000000..0249b5502 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Repositories/RepositoryBase.cs @@ -0,0 +1,411 @@ +using System.Linq.Expressions; + +using ConduitLLM.Configuration.Entities.Interfaces; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Functions.Entities.Interfaces; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Abstract base class providing common repository functionality for CRUD operations. +/// Derived classes only need to implement GetDbSet() and can override other methods as needed. +/// Constrains on IIdentifiableEntity to support both configuration entities (IEntity) +/// and function entities (IIdentifiableEntity) without duplication. +/// +/// The entity type +/// The primary key type (must implement IEquatable) +public abstract class RepositoryBase : IRepositoryBase + where TEntity : class, IIdentifiableEntity + where TKey : IEquatable +{ + /// + /// The database context factory for creating short-lived contexts. + /// + protected readonly IDbContextFactory DbContextFactory; + + /// + /// The logger instance for this repository. + /// + protected readonly ILogger Logger; + + /// + /// Maximum page size for paginated queries. Override in derived class if needed. + /// + protected virtual int MaxPageSize => 100; + + /// + /// Default page size when page size is not specified or invalid. + /// + protected virtual int DefaultPageSize => 20; + + /// + /// Gets the entity type name for logging purposes. + /// + protected virtual string EntityTypeName => typeof(TEntity).Name; + + /// + /// Creates a new instance of the repository base. + /// + /// The database context factory + /// The logger + protected RepositoryBase( + IDbContextFactory dbContextFactory, + ILogger logger) + { + DbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets the DbSet for the entity type. Must be implemented by derived classes. + /// + /// The database context + /// The DbSet for the entity type + protected abstract DbSet GetDbSet(ConduitDbContext context); + + /// + /// Applies default includes for navigation properties. Override to include related entities. + /// + /// The queryable to extend + /// The query with includes applied + protected virtual IQueryable ApplyDefaultIncludes(IQueryable query) + { + return query; + } + + /// + /// Applies default ordering to a query. Override to customize sort order. + /// Default implementation orders by Id descending (newest first). + /// + /// The queryable to order + /// The ordered query + protected virtual IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderByDescending(e => e.Id); + } + + /// + /// Called before creating an entity. Override to set default values. + /// Default implementation sets CreatedAt and UpdatedAt for IAuditableEntity. + /// + /// The entity being created + protected virtual void OnBeforeCreate(TEntity entity) + { + if (entity is IAuditableEntity auditable) + { + var now = DateTime.UtcNow; + if (auditable.CreatedAt == default) + { + auditable.CreatedAt = now; + } + auditable.UpdatedAt = now; + } + } + + /// + /// Called before updating an entity. Override to set default values. + /// Default implementation sets UpdatedAt for IAuditableEntity. + /// + /// The entity being updated + protected virtual void OnBeforeUpdate(TEntity entity) + { + if (entity is IAuditableEntity auditable) + { + auditable.UpdatedAt = DateTime.UtcNow; + } + } + + /// + /// Threshold in milliseconds above which a query is considered slow and logged as a warning. + /// Override in derived classes to customize per-entity. + /// + protected virtual int SlowQueryThresholdMs => 500; + + #region ExecuteAsync helpers + + /// + /// Executes a database operation. When is provided, + /// exceptions are logged with the entity type before re-throwing. + /// Warns when operations exceed . + /// + protected async Task ExecuteAsync( + Func> operation, + CancellationToken cancellationToken = default, + string? operationName = null) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + var result = await operation(context); + sw.Stop(); + + if (sw.ElapsedMilliseconds > SlowQueryThresholdMs && operationName != null) + { + Logger.LogWarning("Slow repository operation: {OperationName} {EntityType} took {ElapsedMs}ms", + operationName, EntityTypeName, sw.ElapsedMilliseconds); + } + + return result; + } + catch (Exception ex) when (operationName != null) + { + Logger.LogError(ex, "Error {OperationName} {EntityType} after {ElapsedMs}ms", + operationName, EntityTypeName, sw.ElapsedMilliseconds); + throw; + } + } + + /// + /// Executes a void database operation. When is provided, + /// exceptions are logged with the entity type before re-throwing. + /// Warns when operations exceed . + /// + protected async Task ExecuteAsync( + Func operation, + CancellationToken cancellationToken = default, + string? operationName = null) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + await operation(context); + sw.Stop(); + + if (sw.ElapsedMilliseconds > SlowQueryThresholdMs && operationName != null) + { + Logger.LogWarning("Slow repository operation: {OperationName} {EntityType} took {ElapsedMs}ms", + operationName, EntityTypeName, sw.ElapsedMilliseconds); + } + } + catch (Exception ex) when (operationName != null) + { + Logger.LogError(ex, "Error {OperationName} {EntityType} after {ElapsedMs}ms", + operationName, EntityTypeName, sw.ElapsedMilliseconds); + throw; + } + } + + #endregion + + #region Standard CRUD operations + + /// + public virtual async Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + { + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + return await query.FirstOrDefaultAsync(e => e.Id.Equals(id), cancellationToken); + }, cancellationToken, $"getting by ID {id}"); + } + + /// + public virtual async Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entity); + + try + { + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + + OnBeforeCreate(entity); + + GetDbSet(context).Add(entity); + await context.SaveChangesAsync(cancellationToken); + + return entity.Id; + } + catch (DbUpdateException ex) + { + Logger.LogError(ex, "Database error creating {EntityType}", EntityTypeName); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error creating {EntityType}", EntityTypeName); + throw; + } + } + + /// + public virtual async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entity); + + try + { + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + + OnBeforeUpdate(entity); + + GetDbSet(context).Update(entity); + int rowsAffected = await context.SaveChangesAsync(cancellationToken); + + return rowsAffected > 0; + } + catch (DbUpdateConcurrencyException ex) + { + Logger.LogWarning(ex, "Concurrency conflict updating {EntityType} with ID {Id} โ€” another process modified this entity", + EntityTypeName, entity.Id); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error updating {EntityType} with ID {Id}", EntityTypeName, entity.Id); + throw; + } + } + + /// + public virtual async Task DeleteAsync(TKey id, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + { + var dbSet = GetDbSet(context); + + var entity = await dbSet.FindAsync(new object[] { id! }, cancellationToken); + if (entity == null) + { + return false; + } + + // Check if entity supports soft delete + if (entity is ISoftDeletable softDeletable) + { + softDeletable.IsDeleted = true; + softDeletable.DeletedAt = DateTime.UtcNow; + dbSet.Update(entity); + Logger.LogDebug("Soft-deleted {EntityType} with ID {Id}", EntityTypeName, id); + } + else + { + dbSet.Remove(entity); + Logger.LogDebug("Hard-deleted {EntityType} with ID {Id}", EntityTypeName, id); + } + + int rowsAffected = await context.SaveChangesAsync(cancellationToken); + return rowsAffected > 0; + }, cancellationToken, $"deleting by ID {id}"); + } + + /// + /// Normalizes pagination parameters by clamping to valid ranges. + /// + protected (int page, int pageSize) NormalizePagination(int page, int pageSize) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = DefaultPageSize; + if (pageSize > MaxPageSize) pageSize = MaxPageSize; + return (page, pageSize); + } + + /// + public virtual async Task<(List Items, int TotalCount)> GetPaginatedAsync( + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + (page, pageSize) = NormalizePagination(page, pageSize); + + return await ExecuteAsync(async context => + { + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + + var totalCount = await query.CountAsync(cancellationToken); + + query = ApplyDefaultOrdering(query); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + }, cancellationToken, $"getting paginated (page {page}, size {pageSize})"); + } + + /// + /// Executes a filtered, paginated query with normalized parameters. + /// Applies default includes and the specified (or default) ordering. + /// + protected async Task<(List Items, int TotalCount)> GetFilteredPaginatedAsync( + Expression> filter, + int page, + int pageSize, + Func, IOrderedQueryable>? orderBy = null, + CancellationToken cancellationToken = default, + string? operationName = null) + { + (page, pageSize) = NormalizePagination(page, pageSize); + + return await ExecuteAsync(async context => + { + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + query = query.Where(filter); + + var totalCount = await query.CountAsync(cancellationToken); + + var orderedQuery = orderBy != null + ? (IQueryable)orderBy(query) + : ApplyDefaultOrdering(query); + + var items = await orderedQuery + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + }, cancellationToken, operationName); + } + + /// + public virtual async Task ExistsAsync(TKey id, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .AnyAsync(e => e.Id.Equals(id), cancellationToken), + cancellationToken, $"checking existence of ID {id}"); + } + + /// + public virtual async Task CountAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await GetDbSet(context).CountAsync(cancellationToken), + cancellationToken, "counting entities"); + } + + /// + public virtual async Task> GetAllUnboundedAsync(CancellationToken cancellationToken = default) + { + Logger.LogWarning( + "Unbounded query executed on {EntityType} via GetAllUnboundedAsync(). " + + "Ensure this is intentional (cache warming, export, migration).", + EntityTypeName); + + return await ExecuteAsync(async context => + { + var query = GetDbSet(context).AsNoTracking(); + query = ApplyDefaultIncludes(query); + query = ApplyDefaultOrdering(query); + return await query.ToListAsync(cancellationToken); + }, cancellationToken, "getting all (unbounded)"); + } + + /// + [Obsolete("Use GetAllUnboundedAsync() for cache warming/exports, or GetPaginatedAsync() for bounded queries.")] + public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await GetAllUnboundedAsync(cancellationToken); + } + + #endregion +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/RequestLogRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/RequestLogRepository.cs index dba53a54d..06b7ba698 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/RequestLogRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/RequestLogRepository.cs @@ -6,15 +6,19 @@ using Microsoft.Extensions.Logging; using ConduitLLM.Configuration.Interfaces; + namespace ConduitLLM.Configuration.Repositories { /// - /// Repository implementation for request logs using Entity Framework Core + /// Repository implementation for request logs using Entity Framework Core. + /// Extends RepositoryBase for standard CRUD operations. /// - public class RequestLogRepository : IRequestLogRepository + public class RequestLogRepository : RepositoryBase, IRequestLogRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; + /// + /// Maximum page size for request log queries + /// + protected override int MaxPageSize => 1000; /// /// Creates a new instance of the repository @@ -24,386 +28,422 @@ public class RequestLogRepository : IRequestLogRepository public RequestLogRepository( IDbContextFactory dbContextFactory, ILogger logger) + : base(dbContextFactory, logger) { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + protected override DbSet GetDbSet(ConduitDbContext context) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.RequestLogs - .AsNoTracking() - .FirstOrDefaultAsync(r => r.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting request log with ID {LogId}", LogSanitizer.SanitizeObject(id)); - throw; - } + return context.RequestLogs; } /// - public async Task> GetAllAsync(CancellationToken cancellationToken = default) + protected override IQueryable ApplyDefaultOrdering(IQueryable query) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.RequestLogs - .AsNoTracking() - .OrderByDescending(r => r.Timestamp) - .ToListAsync(cancellationToken); - } - catch (Exception ex) + return query.OrderByDescending(r => r.Timestamp); + } + + /// + protected override void OnBeforeCreate(RequestLog entity) + { + base.OnBeforeCreate(entity); + + // Ensure timestamp is set + if (entity.Timestamp == default) { - _logger.LogError(ex, "Error getting all request logs"); - throw; + entity.Timestamp = DateTime.UtcNow; } } /// + [Obsolete("Use GetByVirtualKeyIdPaginatedAsync instead. This method loads all records into memory and will be removed in a future version.")] public async Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.RequestLogs + return await context.RequestLogs .AsNoTracking() .Where(r => r.VirtualKeyId == virtualKeyId) .OrderByDescending(r => r.Timestamp) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting request logs for virtual key ID {VirtualKeyId}", LogSanitizer.SanitizeObject(virtualKeyId)); - throw; - } + }, cancellationToken, $"getting by virtual key ID {virtualKeyId}"); } /// - public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + public async Task<(List Logs, int TotalCount)> GetByVirtualKeyIdPaginatedAsync( + int virtualKeyId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) { - try - { - // Ensure dates are UTC for PostgreSQL timestamp with time zone - var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); - var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.RequestLogs - .AsNoTracking() - .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate) - .OrderByDescending(r => r.Timestamp) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting request logs for date range {StartDate} to {EndDate}", - LogSanitizer.SanitizeObject(startDate), LogSanitizer.SanitizeObject(endDate)); - throw; - } + return await GetFilteredPaginatedAsync( + r => r.VirtualKeyId == virtualKeyId, + pageNumber, + pageSize, + q => q.OrderByDescending(r => r.Timestamp), + cancellationToken, + $"getting paginated by virtual key ID {virtualKeyId}"); } /// - public async Task> GetByModelAsync(string modelName, CancellationToken cancellationToken = default) + public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(modelName)) - { - throw new ArgumentException("Model name cannot be null or empty", nameof(modelName)); - } + // Ensure dates are UTC for PostgreSQL timestamp with time zone + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.RequestLogs + return await context.RequestLogs .AsNoTracking() - .Where(r => r.ModelName == modelName) + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate) .OrderByDescending(r => r.Timestamp) .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting request logs for model {ModelName}", LogSanitizer.SanitizeObject(modelName)); - throw; - } + }, cancellationToken, $"getting by date range {startDate:d} to {endDate:d}"); } /// - public async Task<(List Logs, int TotalCount)> GetByDateRangePaginatedAsync( - DateTime startDate, - DateTime endDate, - int pageNumber, - int pageSize, + public async Task> GetByDateRangeFilteredAsync( + DateTime startDate, + DateTime endDate, + string? modelFilter = null, + int? virtualKeyId = null, CancellationToken cancellationToken = default) { - if (pageNumber < 1) - { - throw new ArgumentException("Page number must be greater than or equal to 1", nameof(pageNumber)); - } - - if (pageSize < 1) - { - throw new ArgumentException("Page size must be greater than or equal to 1", nameof(pageSize)); - } + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - // Add upper bound to prevent resource exhaustion - const int maxPageSize = 1000; - if (pageSize > maxPageSize) + // Pre-compute ILIKE pattern outside the EF expression so it's a parameterized literal. + // Escape the LIKE wildcards so a user-supplied "_" or "%" matches itself. + string? likePattern = null; + if (!string.IsNullOrEmpty(modelFilter)) { - _logger.LogWarning("Requested page size {RequestedPageSize} exceeds maximum allowed {MaxPageSize}, limiting to maximum", - LogSanitizer.SanitizeObject(pageSize), LogSanitizer.SanitizeObject(maxPageSize)); - pageSize = maxPageSize; + var escaped = modelFilter + .Replace("\\", "\\\\") + .Replace("%", "\\%") + .Replace("_", "\\_"); + likePattern = $"%{escaped}%"; } - try + return await ExecuteAsync(async context => { - // Ensure dates are UTC for PostgreSQL timestamp with time zone - var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); - var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Build the query with date range filter - var query = dbContext.RequestLogs + var query = context.RequestLogs .AsNoTracking() .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate); - // Get total count - var totalCount = await query.CountAsync(cancellationToken); + if (likePattern != null) + { + query = query.Where(r => EF.Functions.ILike(r.ModelName, likePattern)); + } - // Get paginated data - var logs = await query + if (virtualKeyId.HasValue) + { + var vkId = virtualKeyId.Value; + query = query.Where(r => r.VirtualKeyId == vkId); + } + + return await query .OrderByDescending(r => r.Timestamp) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) .ToListAsync(cancellationToken); - - return (logs, totalCount); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting paginated request logs for date range {StartDate} to {EndDate}, page {PageNumber}, size {PageSize}", - LogSanitizer.SanitizeObject(startDate), LogSanitizer.SanitizeObject(endDate), - LogSanitizer.SanitizeObject(pageNumber), LogSanitizer.SanitizeObject(pageSize)); - throw; - } + }, cancellationToken, $"getting filtered by date range {startDate:d} to {endDate:d}"); } /// - public async Task<(List Logs, int TotalCount)> GetPaginatedAsync(int pageNumber, int pageSize, CancellationToken cancellationToken = default) + public async Task<(List Logs, int TotalCount)> GetByModelPaginatedAsync( + string modelName, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) { - if (pageNumber < 1) - { - throw new ArgumentException("Page number must be greater than or equal to 1", nameof(pageNumber)); - } - - if (pageSize < 1) + if (string.IsNullOrEmpty(modelName)) { - throw new ArgumentException("Page size must be greater than or equal to 1", nameof(pageSize)); + throw new ArgumentException("Model name cannot be null or empty", nameof(modelName)); } - // Add upper bound to prevent resource exhaustion - const int maxPageSize = 1000; - if (pageSize > maxPageSize) - { - _logger.LogWarning("Requested page size {RequestedPageSize} exceeds maximum allowed {MaxPageSize}, limiting to maximum", LogSanitizer.SanitizeObject(pageSize), LogSanitizer.SanitizeObject(maxPageSize)); - pageSize = maxPageSize; - } + return await GetFilteredPaginatedAsync( + r => r.ModelName == modelName, + pageNumber, + pageSize, + q => q.OrderByDescending(r => r.Timestamp), + cancellationToken, + $"getting paginated by model {LoggingSanitizer.S(modelName)}"); + } - try + /// + public async Task> GetDistinctModelsAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Get total count - var totalCount = await dbContext.RequestLogs.CountAsync(cancellationToken); - - // Get paginated data - var logs = await dbContext.RequestLogs + return await context.RequestLogs .AsNoTracking() - .OrderByDescending(r => r.Timestamp) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) + .Where(r => r.ModelName != null && r.ModelName != "") + .Select(r => r.ModelName!) + .Distinct() + .OrderBy(m => m) .ToListAsync(cancellationToken); + }, cancellationToken, "getting distinct models"); + } - return (logs, totalCount); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting paginated request logs for page {PageNumber}, size {PageSize}", - LogSanitizer.SanitizeObject(pageNumber), LogSanitizer.SanitizeObject(pageSize)); - throw; - } + /// + public async Task<(List Logs, int TotalCount)> GetByDateRangePaginatedAsync( + DateTime startDate, + DateTime endDate, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) + { + // Ensure dates are UTC for PostgreSQL timestamp with time zone + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); + + return await GetFilteredPaginatedAsync( + r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate, + pageNumber, + pageSize, + q => q.OrderByDescending(r => r.Timestamp), + cancellationToken, + $"getting paginated by date range {startDate:d} to {endDate:d}"); } /// - public async Task CreateAsync(RequestLog requestLog, CancellationToken cancellationToken = default) + public async Task GetUsageStatisticsAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) { - if (requestLog == null) - { - throw new ArgumentNullException(nameof(requestLog)); - } + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + // Get summary and model breakdown via database-level aggregation + var summaryTask = GetSummaryAsync(utcStartDate, utcEndDate, cancellationToken); + var modelTask = GetAggregatedByModelAsync(utcStartDate, utcEndDate, cancellationToken); + await Task.WhenAll(summaryTask, modelTask); + + var summary = await summaryTask; + var modelAggregations = await modelTask; - // Ensure timestamp is set - if (requestLog.Timestamp == default) + var modelUsageDict = modelAggregations.ToDictionary( + m => m.ModelName, + m => new ModelUsage { - requestLog.Timestamp = DateTime.UtcNow; + RequestCount = m.RequestCount, + Cost = m.TotalCost, + InputTokens = (int)Math.Min(m.InputTokens, int.MaxValue), + OutputTokens = (int)Math.Min(m.OutputTokens, int.MaxValue) } + ); - dbContext.RequestLogs.Add(requestLog); - await dbContext.SaveChangesAsync(cancellationToken); - return requestLog.Id; - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating request log for endpoint '{RequestPath}'", - LogSanitizer.SanitizeObject(requestLog.RequestPath ?? "unknown")); - throw; - } - catch (Exception ex) + return new UsageStatisticsDto { - _logger.LogError(ex, "Error creating request log for endpoint '{RequestPath}'", - LogSanitizer.SanitizeObject(requestLog.RequestPath ?? "unknown")); - throw; - } + TotalRequests = summary.TotalRequests, + TotalCost = summary.TotalCost, + AverageResponseTimeMs = summary.AverageResponseTimeMs, + TotalInputTokens = (int)Math.Min(summary.TotalInputTokens, int.MaxValue), + TotalOutputTokens = (int)Math.Min(summary.TotalOutputTokens, int.MaxValue), + ModelUsage = modelUsageDict + }; } + #region Database-Level Aggregation Methods + /// - public async Task UpdateAsync(RequestLog requestLog, CancellationToken cancellationToken = default) + public async Task> GetCostsByDateAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) { - if (requestLog == null) - { - throw new ArgumentNullException(nameof(requestLog)); - } + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return await context.RequestLogs + .AsNoTracking() + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate) + .GroupBy(r => r.Timestamp.Date) + .Select(g => new DateCostAggregation + { + Date = g.Key, + TotalCost = g.Sum(r => r.Cost), + RequestCount = g.Count() + }) + .OrderBy(d => d.Date) + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting daily costs for {startDate:d} to {endDate:d}"); + } - // Ensure the entity is tracked - dbContext.RequestLogs.Update(requestLog); + /// + public async Task> GetAggregatedByModelAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (DbUpdateConcurrencyException ex) + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Concurrency error updating request log with ID {LogId}", LogSanitizer.SanitizeObject(requestLog.Id)); - - // Handle concurrency issues by reloading and reapplying changes if needed - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var existingEntity = await dbContext.RequestLogs.FindAsync(new object[] { requestLog.Id }, cancellationToken); - - if (existingEntity == null) + return await context.RequestLogs + .AsNoTracking() + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate) + .GroupBy(r => r.ModelName) + .Select(g => new ModelAggregation { - return false; - } + ModelName = g.Key ?? "Unknown", + TotalCost = g.Sum(r => r.Cost), + RequestCount = g.Count(), + InputTokens = g.Sum(r => (long)r.InputTokens), + OutputTokens = g.Sum(r => (long)r.OutputTokens), + CachedInputTokens = g.Sum(r => (long)(r.CachedInputTokens ?? 0)), + CachedWriteTokens = g.Sum(r => (long)(r.CachedWriteTokens ?? 0)) + }) + .OrderByDescending(m => m.TotalCost) + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting model aggregations for {startDate:d} to {endDate:d}"); + } - // Update properties - dbContext.Entry(existingEntity).CurrentValues.SetValues(requestLog); + /// + public async Task> GetAggregatedByModelForVirtualKeyAsync( + int virtualKeyId, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception retryEx) - { - _logger.LogError(retryEx, "Error during retry of request log update with ID {LogId}", LogSanitizer.SanitizeObject(requestLog.Id)); - throw; - } - } - catch (Exception ex) + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Error updating request log with ID {LogId}", - LogSanitizer.SanitizeObject(requestLog.Id)); - throw; - } + return await context.RequestLogs + .AsNoTracking() + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate && r.VirtualKeyId == virtualKeyId) + .GroupBy(r => r.ModelName) + .Select(g => new ModelAggregation + { + ModelName = g.Key ?? "Unknown", + TotalCost = g.Sum(r => r.Cost), + RequestCount = g.Count(), + InputTokens = g.Sum(r => (long)r.InputTokens), + OutputTokens = g.Sum(r => (long)r.OutputTokens), + CachedInputTokens = g.Sum(r => (long)(r.CachedInputTokens ?? 0)), + CachedWriteTokens = g.Sum(r => (long)(r.CachedWriteTokens ?? 0)) + }) + .OrderByDescending(m => m.TotalCost) + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting model aggregations for virtual key {virtualKeyId}"); } /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + public async Task> GetAggregatedByVirtualKeyAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) { - try + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); + + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var requestLog = await dbContext.RequestLogs.FindAsync(new object[] { id }, cancellationToken); + return await context.RequestLogs + .AsNoTracking() + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate) + .GroupBy(r => r.VirtualKeyId) + .Select(g => new VirtualKeyAggregation + { + VirtualKeyId = g.Key, + TotalCost = g.Sum(r => r.Cost), + RequestCount = g.Count(), + LastUsed = g.Max(r => r.Timestamp), + UniqueModels = g.Select(r => r.ModelName).Distinct().Count() + }) + .OrderByDescending(v => v.TotalCost) + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting virtual key aggregations for {startDate:d} to {endDate:d}"); + } - if (requestLog == null) - { - return false; - } + /// + public async Task GetSummaryAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - dbContext.RequestLogs.Remove(requestLog); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Error deleting request log with ID {LogId}", LogSanitizer.SanitizeObject(id)); - throw; - } + var summary = await context.RequestLogs + .AsNoTracking() + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate) + .GroupBy(r => 1) // Single group for whole-set aggregation + .Select(g => new RequestLogSummary + { + TotalRequests = g.Count(), + TotalCost = g.Sum(r => r.Cost), + TotalInputTokens = g.Sum(r => (long)r.InputTokens), + TotalOutputTokens = g.Sum(r => (long)r.OutputTokens), + TotalCachedInputTokens = g.Sum(r => (long)(r.CachedInputTokens ?? 0)), + TotalCachedWriteTokens = g.Sum(r => (long)(r.CachedWriteTokens ?? 0)), + AverageResponseTimeMs = g.Average(r => r.ResponseTimeMs), + SuccessCount = g.Sum(r => (r.StatusCode ?? 0) >= 200 && (r.StatusCode ?? 0) < 300 ? 1 : 0), + ErrorCount = g.Sum(r => (r.StatusCode ?? 0) >= 400 ? 1 : 0) + }) + .FirstOrDefaultAsync(cancellationToken); + + return summary ?? new RequestLogSummary(); + }, cancellationToken, $"getting summary for {startDate:d} to {endDate:d}"); } /// - public async Task GetUsageStatisticsAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + public async Task GetSummaryForVirtualKeyAsync( + int virtualKeyId, DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - var logs = await dbContext.RequestLogs + return await ExecuteAsync(async context => + { + var summary = await context.RequestLogs .AsNoTracking() - .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate) - .ToListAsync(cancellationToken); + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate && r.VirtualKeyId == virtualKeyId) + .GroupBy(r => 1) + .Select(g => new RequestLogSummary + { + TotalRequests = g.Count(), + TotalCost = g.Sum(r => r.Cost), + TotalInputTokens = g.Sum(r => (long)r.InputTokens), + TotalOutputTokens = g.Sum(r => (long)r.OutputTokens), + TotalCachedInputTokens = g.Sum(r => (long)(r.CachedInputTokens ?? 0)), + TotalCachedWriteTokens = g.Sum(r => (long)(r.CachedWriteTokens ?? 0)), + AverageResponseTimeMs = g.Average(r => r.ResponseTimeMs), + SuccessCount = g.Sum(r => (r.StatusCode ?? 0) >= 200 && (r.StatusCode ?? 0) < 300 ? 1 : 0), + ErrorCount = g.Sum(r => (r.StatusCode ?? 0) >= 400 ? 1 : 0) + }) + .FirstOrDefaultAsync(cancellationToken); - // Calculate statistics - var totalRequests = logs.Count; - var totalInputTokens = logs.Sum(r => r.InputTokens); - var totalOutputTokens = logs.Sum(r => r.OutputTokens); - var totalCost = logs.Sum(r => r.Cost); + return summary ?? new RequestLogSummary(); + }, cancellationToken, $"getting summary for virtual key {virtualKeyId}"); + } - // Get model usage - var modelUsageDict = logs - .GroupBy(r => r.ModelName) - .ToDictionary( - g => g.Key ?? "Unknown", - g => new ModelUsage - { - RequestCount = g.Count(), - Cost = g.Sum(r => r.Cost), - InputTokens = g.Sum(r => r.InputTokens), - OutputTokens = g.Sum(r => r.OutputTokens) - } - ); + /// + public async Task> GetDailyStatisticsAsync( + DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + var utcStartDate = DateTime.SpecifyKind(startDate, DateTimeKind.Utc); + var utcEndDate = DateTime.SpecifyKind(endDate, DateTimeKind.Utc); - // Create result - var result = new UsageStatisticsDto - { - TotalRequests = totalRequests, - TotalCost = totalCost, - AverageResponseTimeMs = logs.Count() > 0 ? logs.Average(r => r.ResponseTimeMs) : 0, - TotalInputTokens = logs.Sum(r => r.InputTokens), - TotalOutputTokens = logs.Sum(r => r.OutputTokens), - ModelUsage = modelUsageDict - }; - - return result; - } - catch (Exception ex) + return await ExecuteAsync(async context => { - _logger.LogError(ex, "Error getting usage statistics for date range {StartDate} to {EndDate}", - LogSanitizer.SanitizeObject(startDate), LogSanitizer.SanitizeObject(endDate)); - throw; - } + return await context.RequestLogs + .AsNoTracking() + .Where(r => r.Timestamp >= utcStartDate && r.Timestamp <= utcEndDate) + .GroupBy(r => r.Timestamp.Date) + .Select(g => new DailyStatisticsAggregation + { + Date = g.Key, + RequestCount = g.Count(), + Cost = g.Sum(r => r.Cost), + InputTokens = g.Sum(r => (long)r.InputTokens), + OutputTokens = g.Sum(r => (long)r.OutputTokens), + CachedInputTokens = g.Sum(r => (long)(r.CachedInputTokens ?? 0)), + CachedWriteTokens = g.Sum(r => (long)(r.CachedWriteTokens ?? 0)), + AverageResponseTime = g.Average(r => r.ResponseTimeMs), + ErrorCount = g.Sum(r => (r.StatusCode ?? 0) >= 400 ? 1 : 0) + }) + .OrderBy(s => s.Date) + .ToListAsync(cancellationToken); + }, cancellationToken, $"getting daily statistics for {startDate:d} to {endDate:d}"); } + #endregion + /// public async Task UpdateCostByTaskIdAsync( string taskId, @@ -418,13 +458,11 @@ public async Task UpdateCostByTaskIdAsync( throw new ArgumentException("Task ID cannot be null or empty", nameof(taskId)); } - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - // Find the request log by task ID in the metadata JSONB column // Using PostgreSQL JSONB ->> operator to extract text value - var requestLog = await dbContext.RequestLogs + var requestLog = await context.RequestLogs .FromSqlRaw( @"SELECT * FROM ""RequestLogs"" WHERE ""Metadata"" ->> 'taskId' = {0} LIMIT 1", taskId) @@ -432,7 +470,7 @@ public async Task UpdateCostByTaskIdAsync( if (requestLog == null) { - _logger.LogWarning("Request log not found for task ID {TaskId}", LogSanitizer.SanitizeObject(taskId)); + Logger.LogWarning("Request log not found for task ID {TaskId}", LoggingSanitizer.S(taskId)); return false; } @@ -478,26 +516,21 @@ public async Task UpdateCostByTaskIdAsync( } catch (System.Text.Json.JsonException ex) { - _logger.LogWarning(ex, "Failed to parse metadata for task ID {TaskId}, skipping metadata update", - LogSanitizer.SanitizeObject(taskId)); + Logger.LogWarning(ex, "Failed to parse metadata for task ID {TaskId}, skipping metadata update", + LoggingSanitizer.S(taskId)); } } // Save changes - dbContext.RequestLogs.Update(requestLog); - var rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); + context.RequestLogs.Update(requestLog); + var rowsAffected = await context.SaveChangesAsync(cancellationToken); - _logger.LogInformation( + Logger.LogInformation( "Updated request log for task {TaskId}: Cost=${Cost}, Model={Model}, Duration={Duration}s", - LogSanitizer.SanitizeObject(taskId), cost, modelName ?? requestLog.ModelName, durationSeconds); + LoggingSanitizer.S(taskId), cost, modelName ?? requestLog.ModelName, durationSeconds); return rowsAffected > 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating request log for task ID {TaskId}", LogSanitizer.SanitizeObject(taskId)); - throw; - } + }, cancellationToken, $"updating cost for task {LoggingSanitizer.S(taskId)}"); } /// diff --git a/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyGroupRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyGroupRepository.cs index 5defaab87..374ab6da9 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyGroupRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyGroupRepository.cs @@ -8,121 +8,142 @@ namespace ConduitLLM.Configuration.Repositories; /// -/// Repository for managing virtual key groups +/// Repository implementation for managing virtual key groups. +/// Extends RepositoryBase for standard CRUD operations and implements domain-specific methods. /// -public class VirtualKeyGroupRepository : IVirtualKeyGroupRepository +public class VirtualKeyGroupRepository : RepositoryBase, IVirtualKeyGroupRepository { - private readonly ConduitDbContext _context; - private readonly ILogger _logger; - /// - /// Initializes a new instance of the VirtualKeyGroupRepository + /// Creates a new instance of the VirtualKeyGroupRepository. /// - public VirtualKeyGroupRepository(ConduitDbContext context, ILogger logger) + /// The database context factory + /// The logger + public VirtualKeyGroupRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - _context = context; - _logger = logger; } - /// - public async Task GetByIdAsync(int id) + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.VirtualKeyGroups; + + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) { - return await _context.VirtualKeyGroups - .FirstOrDefaultAsync(g => g.Id == id); + return query.Include(g => g.VirtualKeys); } - /// - public async Task GetByIdWithKeysAsync(int id) + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) { - return await _context.VirtualKeyGroups - .Include(g => g.VirtualKeys) - .FirstOrDefaultAsync(g => g.Id == id); + return query.OrderBy(g => g.GroupName); } - /// - public async Task GetByKeyIdAsync(int virtualKeyId) + /// + /// Overrides CreateAsync to handle initial balance transaction creation. + /// + public override async Task CreateAsync(VirtualKeyGroup entity, CancellationToken cancellationToken = default) { - var key = await _context.VirtualKeys - .Include(k => k.VirtualKeyGroup) - .FirstOrDefaultAsync(k => k.Id == virtualKeyId); - - return key?.VirtualKeyGroup; + ArgumentNullException.ThrowIfNull(entity); + + try + { + return await ExecuteAsync(async context => + { + OnBeforeCreate(entity); + + GetDbSet(context).Add(entity); + await context.SaveChangesAsync(cancellationToken); + + // If group was created with initial balance, create a transaction record + if (entity.Balance > 0) + { + var transaction = CreateTransaction( + entity.Id, + entity.Balance, + entity.Balance, + TransactionType.Credit, + ReferenceType.Initial, + "Initial balance" + ); + + context.VirtualKeyGroupTransactions.Add(transaction); + await context.SaveChangesAsync(cancellationToken); + } + + Logger.LogInformation("Created virtual key group {GroupId} with name {GroupName}", + entity.Id, entity.GroupName); + + return entity.Id; + }, cancellationToken); + } + catch (DbUpdateException ex) + { + Logger.LogError(ex, "Database error creating virtual key group"); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error creating virtual key group"); + throw; + } } - /// - public async Task> GetAllAsync() + /// + /// Overrides UpdateAsync to provide logging. + /// + public override async Task UpdateAsync(VirtualKeyGroup entity, CancellationToken cancellationToken = default) { - return await _context.VirtualKeyGroups - .Include(g => g.VirtualKeys) - .OrderBy(g => g.GroupName) - .ToListAsync(); + var result = await base.UpdateAsync(entity, cancellationToken); + + if (result) + { + Logger.LogInformation("Updated virtual key group {GroupId}", entity.Id); + } + + return result; } - /// - public async Task CreateAsync(VirtualKeyGroup group) + /// + /// Overrides DeleteAsync to provide logging. + /// + public override async Task DeleteAsync(int id, CancellationToken cancellationToken = default) { - group.CreatedAt = DateTime.UtcNow; - group.UpdatedAt = DateTime.UtcNow; - - _context.VirtualKeyGroups.Add(group); - await _context.SaveChangesAsync(); - - // If group was created with initial balance, create a transaction record - if (group.Balance > 0) + var result = await base.DeleteAsync(id, cancellationToken); + + if (result) { - var transaction = CreateTransaction( - group.Id, - group.Balance, - group.Balance, - TransactionType.Credit, - ReferenceType.Initial, - "Initial balance" - ); - - _context.VirtualKeyGroupTransactions.Add(transaction); - await _context.SaveChangesAsync(); + Logger.LogInformation("Deleted virtual key group {GroupId}", id); } - - _logger.LogInformation("Created virtual key group {GroupId} with name {GroupName}", - group.Id, group.GroupName); - - return group.Id; + + return result; } /// - public async Task UpdateAsync(VirtualKeyGroup group) + public async Task GetByIdWithKeysAsync(int id) { - group.UpdatedAt = DateTime.UtcNow; - - _context.VirtualKeyGroups.Update(group); - var result = await _context.SaveChangesAsync(); - - if (result > 0) - { - _logger.LogInformation("Updated virtual key group {GroupId}", group.Id); - } - - return result > 0; + return await ExecuteAsync(async context => + await GetDbSet(context) + .Include(g => g.VirtualKeys) + .AsNoTracking() + .FirstOrDefaultAsync(g => g.Id == id), + operationName: $"getting by ID {id} with keys"); } /// - public async Task DeleteAsync(int id) + public async Task GetByKeyIdAsync(int virtualKeyId) { - var group = await GetByIdAsync(id); - if (group == null) - { - return false; - } - - _context.VirtualKeyGroups.Remove(group); - var result = await _context.SaveChangesAsync(); - - if (result > 0) + return await ExecuteAsync(async context => { - _logger.LogInformation("Deleted virtual key group {GroupId}", id); - } - - return result > 0; + var key = await context.VirtualKeys + .Include(k => k.VirtualKeyGroup) + .AsNoTracking() + .FirstOrDefaultAsync(k => k.Id == virtualKeyId); + + return key?.VirtualKeyGroup; + }, operationName: $"getting by key ID {virtualKeyId}"); } /// @@ -140,61 +161,83 @@ public async Task AdjustBalanceAsync(int groupId, decimal amount, strin /// public async Task AdjustBalanceAsync(int groupId, decimal amount, string? description, string? initiatedBy, ReferenceType referenceType, string? referenceId = null) { - var group = await GetByIdAsync(groupId); - if (group == null) + try { - throw new InvalidOperationException($"Virtual key group {groupId} not found"); - } + return await ExecuteAsync(async context => + { + var group = await GetDbSet(context).FirstOrDefaultAsync(g => g.Id == groupId); + if (group == null) + { + throw new InvalidOperationException($"Virtual key group {groupId} not found"); + } - var previousBalance = group.Balance; - group.Balance += amount; + var previousBalance = group.Balance; + group.Balance += amount; - if (amount > 0) - { - group.LifetimeCreditsAdded += amount; - } - else - { - group.LifetimeSpent += Math.Abs(amount); - } + if (amount > 0) + { + group.LifetimeCreditsAdded += amount; + } + else + { + group.LifetimeSpent += Math.Abs(amount); + } - group.UpdatedAt = DateTime.UtcNow; + group.UpdatedAt = DateTime.UtcNow; - // Create transaction record - var transaction = CreateTransaction( - groupId, - amount, - group.Balance, - amount > 0 ? TransactionType.Credit : TransactionType.Debit, - referenceType, - description ?? (amount > 0 ? "Credits added" : "Usage deducted"), - referenceId, - initiatedBy ?? "System" - ); + // Create transaction record + var transaction = CreateTransaction( + groupId, + amount, + group.Balance, + amount > 0 ? TransactionType.Credit : TransactionType.Debit, + referenceType, + description ?? (amount > 0 ? "Credits added" : "Usage deducted"), + referenceId, + initiatedBy ?? "System" + ); - _context.VirtualKeyGroupTransactions.Add(transaction); + context.VirtualKeyGroupTransactions.Add(transaction); - await _context.SaveChangesAsync(); + await context.SaveChangesAsync(); - _logger.LogInformation("Adjusted balance for group {GroupId} by {Amount}. Previous: {PreviousBalance}, New: {Balance}, ReferenceType: {ReferenceType}", - groupId, amount, previousBalance, group.Balance, referenceType); + Logger.LogInformation("Adjusted balance for group {GroupId} by {Amount}. Previous: {PreviousBalance}, New: {Balance}, ReferenceType: {ReferenceType}", + groupId, amount, previousBalance, group.Balance, referenceType); - return group.Balance; + return group.Balance; + }); + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error adjusting balance for virtual key group {GroupId}", groupId); + throw; + } } /// - public async Task> GetLowBalanceGroupsAsync(decimal threshold) + public async Task<(List Items, int TotalCount)> GetLowBalanceGroupsPaginatedAsync( + decimal threshold, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) { - return await _context.VirtualKeyGroups - .Where(g => g.Balance < threshold) - .OrderBy(g => g.Balance) - .ToListAsync(); + return await GetFilteredPaginatedAsync( + g => g.Balance < threshold, + pageNumber, + pageSize, + q => q.OrderBy(g => g.Balance), + cancellationToken, + $"getting low balance groups (threshold: {threshold})"); } /// /// Creates a transaction record for a virtual key group /// - private VirtualKeyGroupTransaction CreateTransaction( + private static VirtualKeyGroupTransaction CreateTransaction( int groupId, decimal amount, decimal balanceAfter, @@ -219,4 +262,4 @@ private VirtualKeyGroupTransaction CreateTransaction( CreatedAt = DateTime.UtcNow }; } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyGroupTransactionRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyGroupTransactionRepository.cs new file mode 100644 index 000000000..64ce1aeee --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyGroupTransactionRepository.cs @@ -0,0 +1,157 @@ +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Enums; +using ConduitLLM.Configuration.Interfaces; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for virtual key group transactions using Entity Framework Core. +/// Extends RepositoryBase for standard CRUD operations and implements domain-specific methods. +/// +public class VirtualKeyGroupTransactionRepository : RepositoryBase, IVirtualKeyGroupTransactionRepository +{ + /// + /// Creates a new instance of the repository + /// + /// The database context factory + /// The logger + public VirtualKeyGroupTransactionRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) + { + } + + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.VirtualKeyGroupTransactions; + + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + { + return query.Include(t => t.VirtualKeyGroup); + } + + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderByDescending(t => t.CreatedAt); + } + + /// + protected override void OnBeforeCreate(VirtualKeyGroupTransaction entity) + { + base.OnBeforeCreate(entity); + + // Set CreatedAt if not provided + if (entity.CreatedAt == default) + { + entity.CreatedAt = DateTime.UtcNow; + } + } + + /// + public async Task> GetByGroupIdAsync(int groupId, CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Where(t => t.VirtualKeyGroupId == groupId && !t.IsDeleted) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken), + cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting transactions for virtual key group with ID {GroupId}", groupId); + throw; + } + } + + /// + public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Include(t => t.VirtualKeyGroup) + .Where(t => t.CreatedAt >= startDate && t.CreatedAt <= endDate && !t.IsDeleted) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken), + cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting transactions for date range {StartDate} to {EndDate}", startDate, endDate); + throw; + } + } + + /// + public async Task> GetByGroupIdAndDateRangeAsync( + int groupId, + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .AsNoTracking() + .Where(t => t.VirtualKeyGroupId == groupId && t.CreatedAt >= startDate && t.CreatedAt <= endDate && !t.IsDeleted) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken), + cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting transactions for virtual key group {GroupId} and date range {StartDate} to {EndDate}", + groupId, startDate, endDate); + throw; + } + } + + /// + public async Task GetTotalCreditsAsync(int groupId, CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .Where(t => t.VirtualKeyGroupId == groupId && t.TransactionType == TransactionType.Credit && !t.IsDeleted) + .SumAsync(t => t.Amount, cancellationToken), + cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting total credits for virtual key group {GroupId}", groupId); + throw; + } + } + + /// + public async Task GetTotalDebitsAsync(int groupId, CancellationToken cancellationToken = default) + { + try + { + return await ExecuteAsync(async context => + await GetDbSet(context) + .Where(t => t.VirtualKeyGroupId == groupId && t.TransactionType == TransactionType.Debit && !t.IsDeleted) + .SumAsync(t => t.Amount, cancellationToken), + cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting total debits for virtual key group {GroupId}", groupId); + throw; + } + } +} diff --git a/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyRepository.cs index e61a18f55..f35e66018 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeyRepository.cs @@ -1,10 +1,10 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Utilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Configuration.Repositories { /// @@ -13,8 +13,8 @@ namespace ConduitLLM.Configuration.Repositories /// /// /// This repository provides data access operations for virtual key entities using Entity Framework Core. - /// It implements the interface and provides concrete implementations - /// for all required operations. + /// It extends for standard CRUD operations and implements + /// for domain-specific virtual key operations. /// /// /// The implementation follows these principles: @@ -22,187 +22,69 @@ namespace ConduitLLM.Configuration.Repositories /// /// Using short-lived DbContext instances for better performance and reliability /// Comprehensive error handling with detailed logging - /// Optimistic concurrency control for update operations + /// Optimistic concurrency control for update operations with retry logic /// Non-tracking queries for read operations to improve performance /// Automatic timestamp management for auditing purposes /// - /// - /// The repository requires a database factory to create DbContext instances on demand, - /// ensuring that each operation uses a fresh context with a clean change tracker. - /// /// - public class VirtualKeyRepository : IVirtualKeyRepository + public class VirtualKeyRepository : RepositoryBase, IVirtualKeyRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - /// /// Initializes a new instance of the class. /// /// The database context factory used to create DbContext instances. /// The logger for recording diagnostic information. /// Thrown when dbContextFactory or logger is null. - /// - /// This constructor initializes the repository with the required dependencies: - /// - /// - /// - /// A DbContext factory that creates ConfigurationDbContext instances for data access operations. - /// Using a factory pattern allows the repository to create short-lived context instances for - /// each operation, which is recommended for web applications. - /// - /// - /// - /// - /// A logger for capturing diagnostic information and errors during repository operations. - /// This is especially important for data access operations to help diagnose issues in production. - /// - /// - /// - /// public VirtualKeyRepository( IDbContextFactory dbContextFactory, ILogger logger) + : base(dbContextFactory, logger) { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeys - .AsNoTracking() - .Include(vk => vk.VirtualKeyGroup) - .FirstOrDefaultAsync(vk => vk.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting virtual key with ID {KeyId}", LogSanitizer.SanitizeObject(id)); - throw; - } - } + protected override DbSet GetDbSet(ConduitDbContext context) => context.VirtualKeys; /// - public async Task GetByKeyHashAsync(string keyHash, CancellationToken cancellationToken = default) + protected override IQueryable ApplyDefaultIncludes(IQueryable query) { - if (string.IsNullOrEmpty(keyHash)) - { - throw new ArgumentException("Key hash cannot be null or empty", nameof(keyHash)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeys - .AsNoTracking() - .FirstOrDefaultAsync(vk => vk.KeyHash == keyHash, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting virtual key by hash"); - throw; - } + return query.Include(vk => vk.VirtualKeyGroup); } /// - public async Task> GetAllAsync(CancellationToken cancellationToken = default) + protected override IQueryable ApplyDefaultOrdering(IQueryable query) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeys - .AsNoTracking() - .OrderBy(vk => vk.KeyName) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting all virtual keys"); - throw; - } - } - - /// - public async Task> GetByVirtualKeyGroupIdAsync(int virtualKeyGroupId, CancellationToken cancellationToken = default) - { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeys - .AsNoTracking() - .Where(vk => vk.VirtualKeyGroupId == virtualKeyGroupId) - .OrderBy(vk => vk.KeyName) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting virtual keys for group {GroupId}", virtualKeyGroupId); - throw; - } + return query.OrderBy(vk => vk.KeyName); } /// - public async Task CreateAsync(VirtualKey virtualKey, CancellationToken cancellationToken = default) + public override async Task UpdateAsync(VirtualKey virtualKey, CancellationToken cancellationToken = default) { - if (virtualKey == null) - { - throw new ArgumentNullException(nameof(virtualKey)); - } + ArgumentNullException.ThrowIfNull(virtualKey); try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - dbContext.VirtualKeys.Add(virtualKey); - await dbContext.SaveChangesAsync(cancellationToken); - return virtualKey.Id; - } - catch (DbUpdateException ex) - { -_logger.LogError(ex, "Database error creating virtual key '{KeyName}'", LoggingSanitizer.S(virtualKey.KeyName)); - throw; - } - catch (Exception ex) - { -_logger.LogError(ex, "Error creating virtual key '{KeyName}'", LoggingSanitizer.S(virtualKey.KeyName)); - throw; - } - } - - /// - public async Task UpdateAsync(VirtualKey virtualKey, CancellationToken cancellationToken = default) - { - if (virtualKey == null) - { - throw new ArgumentNullException(nameof(virtualKey)); - } + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + // Set the updated timestamp + OnBeforeUpdate(virtualKey); // Ensure the entity is tracked - dbContext.VirtualKeys.Update(virtualKey); - - // Set the updated timestamp - virtualKey.UpdatedAt = DateTime.UtcNow; + context.VirtualKeys.Update(virtualKey); // Save changes - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); + int rowsAffected = await context.SaveChangesAsync(cancellationToken); return rowsAffected > 0; } catch (DbUpdateConcurrencyException ex) { - _logger.LogError(ex, "Concurrency error updating virtual key with ID {KeyId}", LogSanitizer.SanitizeObject(virtualKey.Id)); + Logger.LogError(ex, "Concurrency error updating virtual key with ID {KeyId}", LoggingSanitizer.S(virtualKey.Id)); // Handle concurrency issues by reloading and reapplying changes if needed try { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var existingEntity = await dbContext.VirtualKeys.FindAsync(new object[] { virtualKey.Id }, cancellationToken); + await using var context = await DbContextFactory.CreateDbContextAsync(cancellationToken); + var existingEntity = await context.VirtualKeys.FindAsync(new object[] { virtualKey.Id }, cancellationToken); if (existingEntity == null) { @@ -210,56 +92,95 @@ public async Task UpdateAsync(VirtualKey virtualKey, CancellationToken can } // Update properties - dbContext.Entry(existingEntity).CurrentValues.SetValues(virtualKey); + context.Entry(existingEntity).CurrentValues.SetValues(virtualKey); existingEntity.UpdatedAt = DateTime.UtcNow; - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); + int rowsAffected = await context.SaveChangesAsync(cancellationToken); return rowsAffected > 0; } catch (Exception retryEx) { - _logger.LogError(retryEx, "Error during retry of virtual key update with ID {KeyId}", LogSanitizer.SanitizeObject(virtualKey.Id)); + Logger.LogError(retryEx, "Error during retry of virtual key update with ID {KeyId}", LoggingSanitizer.S(virtualKey.Id)); throw; } } catch (Exception ex) { - _logger.LogError(ex, "Error updating virtual key with ID {KeyId}", LogSanitizer.SanitizeObject(virtualKey.Id)); + Logger.LogError(ex, "Error updating virtual key with ID {KeyId}", LoggingSanitizer.S(virtualKey.Id)); throw; } } /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + public async Task GetByKeyHashAsync(string keyHash, CancellationToken cancellationToken = default) { - try + if (string.IsNullOrEmpty(keyHash)) { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var virtualKey = await dbContext.VirtualKeys.FindAsync(new object[] { id }, cancellationToken); + throw new ArgumentException("Key hash cannot be null or empty", nameof(keyHash)); + } - if (virtualKey == null) - { - return false; - } + return await ExecuteAsync(async context => + await context.VirtualKeys + .AsNoTracking() + .FirstOrDefaultAsync(vk => vk.KeyHash == keyHash, cancellationToken), + cancellationToken, "getting by key hash"); + } - dbContext.VirtualKeys.Remove(virtualKey); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) + /// + public async Task<(List Items, int TotalCount)> GetByVirtualKeyGroupIdPaginatedAsync( + int virtualKeyGroupId, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default) + { + return await GetFilteredPaginatedAsync( + vk => vk.VirtualKeyGroupId == virtualKeyGroupId, + pageNumber, + pageSize, + q => q.OrderBy(vk => vk.KeyName), + cancellationToken, + $"getting paginated by group ID {virtualKeyGroupId}"); + } + + /// + public async Task> GetKeyNamesByIdsAsync( + IEnumerable ids, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(ids); + + var idList = ids.ToList(); + if (idList.Count == 0) { - _logger.LogError(ex, "Error deleting virtual key with ID {KeyId}", LogSanitizer.SanitizeObject(id)); - throw; + return new Dictionary(); } + + return await ExecuteAsync(async context => + await context.VirtualKeys + .AsNoTracking() + .Where(vk => idList.Contains(vk.Id)) + .ToDictionaryAsync(vk => vk.Id, vk => vk.KeyName ?? "", cancellationToken), + cancellationToken, $"getting key names for {idList.Count} IDs"); + } + + /// + public async Task CountActiveAsync(CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await context.VirtualKeys + .AsNoTracking() + .Where(vk => vk.IsEnabled && + (vk.ExpiresAt == null || vk.ExpiresAt > DateTime.UtcNow)) + .CountAsync(cancellationToken), + cancellationToken, "counting active"); } /// public async Task DeleteAsync(string keyHash, CancellationToken cancellationToken = default) { - try + return await ExecuteAsync(async context => { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var virtualKey = await dbContext.VirtualKeys + var virtualKey = await context.VirtualKeys .Where(vk => vk.KeyHash == keyHash) .FirstOrDefaultAsync(cancellationToken); @@ -268,18 +189,25 @@ public async Task DeleteAsync(string keyHash, CancellationToken cancellati return false; } - dbContext.VirtualKeys.Remove(virtualKey); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - - _logger.LogInformation("Deleted virtual key with hash {KeyHash}", LogSanitizer.SanitizeObject(keyHash)); + context.VirtualKeys.Remove(virtualKey); + int rowsAffected = await context.SaveChangesAsync(cancellationToken); + + Logger.LogInformation("Deleted virtual key with hash {KeyHash}", LoggingSanitizer.S(keyHash)); return rowsAffected > 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting virtual key with hash {KeyHash}", LogSanitizer.SanitizeObject(keyHash)); - throw; - } + }, cancellationToken, "deleting by key hash"); } + /// + public async Task> GetTopEnabledAsync(int count, CancellationToken cancellationToken = default) + { + return await ExecuteAsync(async context => + await context.VirtualKeys + .AsNoTracking() + .Where(vk => vk.IsEnabled) + .OrderBy(vk => vk.KeyName) + .Take(count) + .ToListAsync(cancellationToken), + cancellationToken, $"getting top {count} enabled"); + } } } diff --git a/Shared/ConduitLLM.Configuration/Repositories/VirtualKeySpendHistoryRepository.cs b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeySpendHistoryRepository.cs index e9c6a3148..74758470f 100644 --- a/Shared/ConduitLLM.Configuration/Repositories/VirtualKeySpendHistoryRepository.cs +++ b/Shared/ConduitLLM.Configuration/Repositories/VirtualKeySpendHistoryRepository.cs @@ -1,212 +1,138 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Repositories +namespace ConduitLLM.Configuration.Repositories; + +/// +/// Repository implementation for virtual key spend history using Entity Framework Core. +/// Extends RepositoryBase for standard CRUD operations and implements domain-specific methods. +/// +public class VirtualKeySpendHistoryRepository : RepositoryBase, IVirtualKeySpendHistoryRepository { /// - /// Repository implementation for virtual key spend history using Entity Framework Core + /// Creates a new instance of the repository /// - public class VirtualKeySpendHistoryRepository : IVirtualKeySpendHistoryRepository + /// The database context factory + /// The logger + public VirtualKeySpendHistoryRepository( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; + } - /// - /// Creates a new instance of the repository - /// - /// The database context factory - /// The logger - public VirtualKeySpendHistoryRepository( - IDbContextFactory dbContextFactory, - ILogger logger) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.VirtualKeySpendHistory; - /// - public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + /// + protected override IQueryable ApplyDefaultIncludes(IQueryable query) + { + return query.Include(h => h.VirtualKey); + } + + /// + protected override IQueryable ApplyDefaultOrdering(IQueryable query) + { + return query.OrderByDescending(h => h.Timestamp); + } + + /// + protected override void OnBeforeCreate(VirtualKeySpendHistory entity) + { + base.OnBeforeCreate(entity); + + // Set timestamp if not provided + if (entity.Timestamp == default) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeySpendHistories - .AsNoTracking() - .Include(h => h.VirtualKey) - .FirstOrDefaultAsync(h => h.Id == id, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting virtual key spend history with ID {HistoryId}", id); - throw; - } + entity.Timestamp = DateTime.UtcNow; } + } - /// - public async Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default) + /// + public async Task> GetByVirtualKeyIdAsync(int virtualKeyId, CancellationToken cancellationToken = default) + { + try { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeySpendHistories + return await ExecuteAsync(async context => + await GetDbSet(context) .AsNoTracking() .Where(h => h.VirtualKeyId == virtualKeyId) .OrderByDescending(h => h.Timestamp) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting spend history for virtual key with ID {VirtualKeyId}", virtualKeyId); - throw; - } + .ToListAsync(cancellationToken), + cancellationToken); } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting spend history for virtual key with ID {VirtualKeyId}", virtualKeyId); + throw; + } + } - /// - public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + /// + public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + try { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeySpendHistories + return await ExecuteAsync(async context => + await GetDbSet(context) .AsNoTracking() .Include(h => h.VirtualKey) .Where(h => h.Timestamp >= startDate && h.Timestamp <= endDate) .OrderByDescending(h => h.Timestamp) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting spend history for date range {StartDate} to {EndDate}", startDate, endDate); - throw; - } + .ToListAsync(cancellationToken), + cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting spend history for date range {StartDate} to {EndDate}", startDate, endDate); + throw; } + } - /// - public async Task> GetByVirtualKeyAndDateRangeAsync( - int virtualKeyId, - DateTime startDate, - DateTime endDate, - CancellationToken cancellationToken = default) + /// + public async Task> GetByVirtualKeyAndDateRangeAsync( + int virtualKeyId, + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken = default) + { + try { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeySpendHistories + return await ExecuteAsync(async context => + await GetDbSet(context) .AsNoTracking() .Where(h => h.VirtualKeyId == virtualKeyId && h.Timestamp >= startDate && h.Timestamp <= endDate) .OrderByDescending(h => h.Timestamp) - .ToListAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting spend history for virtual key {VirtualKeyId} and date range {StartDate} to {EndDate}", - virtualKeyId, startDate, endDate); - throw; - } + .ToListAsync(cancellationToken), + cancellationToken); } - - /// - public async Task CreateAsync(VirtualKeySpendHistory spendHistory, CancellationToken cancellationToken = default) + catch (Exception ex) { - if (spendHistory == null) - { - throw new ArgumentNullException(nameof(spendHistory)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - // Set timestamp if not provided - if (spendHistory.Timestamp == default) - { - spendHistory.Timestamp = DateTime.UtcNow; - } - - dbContext.VirtualKeySpendHistories.Add(spendHistory); - await dbContext.SaveChangesAsync(cancellationToken); - return spendHistory.Id; - } - catch (DbUpdateException ex) - { - _logger.LogError(ex, "Database error creating spend history for virtual key {VirtualKeyId}", - spendHistory.VirtualKeyId); - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating spend history for virtual key {VirtualKeyId}", - spendHistory.VirtualKeyId); - throw; - } - } - - /// - public async Task UpdateAsync(VirtualKeySpendHistory spendHistory, CancellationToken cancellationToken = default) - { - if (spendHistory == null) - { - throw new ArgumentNullException(nameof(spendHistory)); - } - - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - dbContext.VirtualKeySpendHistories.Update(spendHistory); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating spend history with ID {HistoryId}", - spendHistory.Id); - throw; - } + Logger.LogError(ex, "Error getting spend history for virtual key {VirtualKeyId} and date range {StartDate} to {EndDate}", + virtualKeyId, startDate, endDate); + throw; } + } - /// - public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + /// + public async Task GetTotalSpendAsync(int virtualKeyId, CancellationToken cancellationToken = default) + { + try { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var spendHistory = await dbContext.VirtualKeySpendHistories.FindAsync(new object[] { id }, cancellationToken); - - if (spendHistory == null) - { - return false; - } - - dbContext.VirtualKeySpendHistories.Remove(spendHistory); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - return rowsAffected > 0; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting spend history with ID {HistoryId}", id); - throw; - } + return await ExecuteAsync(async context => + await GetDbSet(context) + .Where(h => h.VirtualKeyId == virtualKeyId) + .SumAsync(h => h.Amount, cancellationToken), + cancellationToken); } - - /// - public async Task GetTotalSpendAsync(int virtualKeyId, CancellationToken cancellationToken = default) + catch (Exception ex) { - try - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - return await dbContext.VirtualKeySpendHistories - .Where(h => h.VirtualKeyId == virtualKeyId) - .SumAsync(h => h.Amount, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting total spend for virtual key {VirtualKeyId}", virtualKeyId); - throw; - } + Logger.LogError(ex, "Error getting total spend for virtual key {VirtualKeyId}", virtualKeyId); + throw; } } } diff --git a/Shared/ConduitLLM.Configuration/Services/BatchAuditServiceBase.cs b/Shared/ConduitLLM.Configuration/Services/BatchAuditServiceBase.cs new file mode 100644 index 000000000..cc0fcd807 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Services/BatchAuditServiceBase.cs @@ -0,0 +1,541 @@ +using System.Collections.Concurrent; +using ConduitLLM.Functions.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Prometheus; + +namespace ConduitLLM.Configuration.Services; + +/// +/// Abstract base class for audit services that use batch processing for database writes. +/// Implements the template method pattern for common batch processing functionality. +/// +/// The type of audit event entity that implements IAuditEvent +public abstract class BatchAuditServiceBase : IHostedService, IDisposable + where TEvent : class, IAuditEvent +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly ConcurrentQueue _eventQueue; + private readonly Timer _flushTimer; + private readonly SemaphoreSlim _flushSemaphore; + private bool _disposed; + + // Prometheus metrics for audit service operations + private static readonly Counter AuditEventsQueued = Prometheus.Metrics + .CreateCounter("conduit_audit_events_queued_total", "Total audit events queued", + new CounterConfiguration + { + LabelNames = new[] { "service" } + }); + + private static readonly Counter AuditEventsFlushed = Prometheus.Metrics + .CreateCounter("conduit_audit_events_flushed_total", "Total audit events flushed to database", + new CounterConfiguration + { + LabelNames = new[] { "service", "status" } // status: success, failure + }); + + private static readonly Gauge AuditQueueDepth = Prometheus.Metrics + .CreateGauge("conduit_audit_queue_depth", "Current audit event queue depth", + new GaugeConfiguration + { + LabelNames = new[] { "service" } + }); + + private static readonly Counter AuditCleanupEvents = Prometheus.Metrics + .CreateCounter("conduit_audit_cleanup_events_deleted_total", "Total audit events deleted during cleanup", + new CounterConfiguration + { + LabelNames = new[] { "service" } + }); + + private static readonly Counter AuditCleanupRuns = Prometheus.Metrics + .CreateCounter("conduit_audit_cleanup_runs_total", "Total audit cleanup runs", + new CounterConfiguration + { + LabelNames = new[] { "service", "status" } // status: success, failure + }); + + /// + /// Creates a new instance of the batch audit service base. + /// + /// Service provider for creating scoped DbContexts + /// Logger instance + protected BatchAuditServiceBase( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventQueue = new ConcurrentQueue(); + _flushSemaphore = new SemaphoreSlim(1, 1); + _flushTimer = new Timer(FlushTimerCallback, null, Timeout.Infinite, Timeout.Infinite); + } + + #region Template Methods (Abstract - Must be implemented by derived classes) + + /// + /// Gets the DbSet for the event type from the database context. + /// + /// The database context + /// The DbSet for the event type + protected abstract DbSet GetDbSet(ConduitDbContext context); + + /// + /// Gets the entity name for logging purposes (e.g., "Billing", "Pricing", "FunctionCall", "RequestLog"). + /// + protected abstract string EntityName { get; } + + #endregion + + #region Virtual Properties (Can be overridden by derived classes) + + /// + /// Number of events to process in a single batch. Default: 100 + /// + protected virtual int BatchSize => 100; + + /// + /// Interval in seconds between automatic flush operations. Default: 10 + /// + protected virtual int FlushIntervalSeconds => 10; + + /// + /// Number of days to retain audit events before cleanup. Default: 90 + /// + protected virtual int RetentionDays => 90; + + /// + /// Number of events to delete in a single batch during cleanup. Default: 1000 + /// + protected virtual int CleanupBatchSize => 1000; + + /// + /// If true, uses ExecuteDeleteAsync for bulk deletion. If false, uses batch loop with RemoveRange. + /// Default: false + /// + protected virtual bool UseBulkDelete => false; + + #endregion + + #region Public API + + /// + /// Logs an audit event asynchronously, waiting for flush if batch size is reached. + /// + /// The event to log + /// Thrown when auditEvent is null + public async Task LogEventAsync(TEvent auditEvent) + { + if (auditEvent == null) + throw new ArgumentNullException(nameof(auditEvent)); + + _eventQueue.Enqueue(auditEvent); + AuditEventsQueued.WithLabels(EntityName).Inc(); + AuditQueueDepth.WithLabels(EntityName).Set(_eventQueue.Count); + + if (_eventQueue.Count >= BatchSize) + { + await FlushEventsInternalAsync(wait: true); + } + } + + /// + /// Logs an audit event without waiting (fire-and-forget). + /// Events are queued and flushed in batches. + /// + /// The event to log + public void LogEvent(TEvent auditEvent) + { + if (auditEvent == null) + { + _logger.LogWarning("Attempted to log null {EntityName} audit event", EntityName); + return; + } + + _eventQueue.Enqueue(auditEvent); + AuditEventsQueued.WithLabels(EntityName).Inc(); + var queueCount = _eventQueue.Count; + AuditQueueDepth.WithLabels(EntityName).Set(queueCount); + + if (queueCount >= BatchSize) + { + _logger.LogDebug("{EntityName} audit queue reached batch threshold ({QueueCount}/{BatchSize}), triggering flush", + EntityName, queueCount, BatchSize); + _ = Task.Run(async () => + { + try { await FlushEventsInternalAsync(); } + catch (Exception ex) { _logger.LogError(ex, "Unhandled error during {EntityName} audit batch-threshold flush", EntityName); } + }); + } + } + + /// + /// Forces a flush of all pending audit events to the database. + /// + public async Task FlushEventsAsync() + { + await FlushEventsInternalAsync(wait: true); + } + + /// + /// Removes audit events older than the retention period. + /// + public async Task CleanupOldEventsAsync() + { + _logger.LogInformation("Starting cleanup of {EntityName} audit events older than {RetentionDays} days", + EntityName, RetentionDays); + + try + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var cutoffDate = DateTime.UtcNow.AddDays(-RetentionDays); + + if (UseBulkDelete) + { + var deletedCount = await GetDbSet(context) + .Where(e => e.Timestamp < cutoffDate) + .ExecuteDeleteAsync(); + + if (deletedCount > 0) + { + _logger.LogInformation("Cleanup completed: Deleted {TotalDeleted} {EntityName} audit events older than {CutoffDate}", + deletedCount, EntityName, cutoffDate); + AuditCleanupEvents.WithLabels(EntityName).Inc(deletedCount); + } + AuditCleanupRuns.WithLabels(EntityName, "success").Inc(); + } + else + { + int totalDeleted = 0; + int batchDeleted; + + do + { + var oldEvents = await GetDbSet(context) + .Where(e => e.Timestamp < cutoffDate) + .OrderBy(e => e.Timestamp) + .Take(CleanupBatchSize) + .ToListAsync(); + + if (oldEvents.Count == 0) + break; + + GetDbSet(context).RemoveRange(oldEvents); + await context.SaveChangesAsync(); + + batchDeleted = oldEvents.Count; + totalDeleted += batchDeleted; + + _logger.LogDebug("Deleted {BatchCount} old {EntityName} audit events", batchDeleted, EntityName); + + if (batchDeleted == CleanupBatchSize) + await Task.Delay(100); + + } while (batchDeleted == CleanupBatchSize); + + if (totalDeleted > 0) + { + _logger.LogInformation("Cleanup completed: Deleted {TotalDeleted} {EntityName} audit events older than {CutoffDate}", + totalDeleted, EntityName, cutoffDate); + AuditCleanupEvents.WithLabels(EntityName).Inc(totalDeleted); + } + AuditCleanupRuns.WithLabels(EntityName, "success").Inc(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cleanup old {EntityName} audit events", EntityName); + AuditCleanupRuns.WithLabels(EntityName, "failure").Inc(); + throw; + } + } + + #endregion + + #region IHostedService Implementation + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting {EntityName}AuditService with batch size {BatchSize} and flush interval {FlushInterval}s", + EntityName, BatchSize, FlushIntervalSeconds); + + // Start the flush timer + _flushTimer.Change( + TimeSpan.FromSeconds(FlushIntervalSeconds), + TimeSpan.FromSeconds(FlushIntervalSeconds)); + + // Schedule data retention cleanup + _ = Task.Run(async () => + { + try { await ScheduleDataRetentionAsync(cancellationToken); } + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Unhandled error during {EntityName} data retention scheduling", EntityName); } + }, cancellationToken); + + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping {EntityName}AuditService, flushing remaining events...", EntityName); + + // Stop the timer + _flushTimer?.Change(Timeout.Infinite, 0); + + // Final flush - drain all remaining events + await _flushSemaphore.WaitAsync(cancellationToken); + try + { + var events = new List(); + + while (_eventQueue.TryDequeue(out var auditEvent)) + { + events.Add(auditEvent); + } + + if (events.Count > 0) + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + await GetDbSet(context).AddRangeAsync(events); + await context.SaveChangesAsync(); + + _logger.LogDebug("Final flush of {Count} {EntityName} audit events to database", events.Count, EntityName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to flush remaining {EntityName} audit events to database", EntityName); + } + finally + { + _flushSemaphore.Release(); + } + + _logger.LogInformation("{EntityName}AuditService stopped", EntityName); + } + + #endregion + + #region IDisposable Implementation + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes managed resources. + /// + /// True if called from Dispose(), false if from finalizer + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + _flushTimer?.Dispose(); + _flushSemaphore?.Dispose(); + } + + _disposed = true; + } + + #endregion + + #region Protected Methods (Can be overridden by derived classes) + + /// + /// Schedules periodic data retention cleanup. + /// Default behavior: 5-minute initial delay, then daily cleanup. + /// Override in derived classes to customize (e.g., startup-only cleanup). + /// + /// Cancellation token + protected virtual async Task ScheduleDataRetentionAsync(CancellationToken cancellationToken) + { + // Wait for initial delay before first cleanup + await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await CleanupOldEventsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during {EntityName} audit event cleanup", EntityName); + } + + // Run cleanup daily + await Task.Delay(TimeSpan.FromDays(1), cancellationToken); + } + } + + /// + /// Gets the service provider for creating scoped services. + /// + protected IServiceProvider ServiceProvider => _serviceProvider; + + /// + /// Gets the logger instance. + /// + protected ILogger Logger => _logger; + + #endregion + + #region Query Template Methods + + /// + /// Executes a paginated query with time-range filtering and optional domain-specific filters. + /// Handles scope creation, AsNoTracking, count, ordering by Timestamp desc, and Skip/Take. + /// + /// Start date (inclusive) + /// End date (inclusive) + /// Page number (1-based) + /// Number of items per page + /// Optional function to apply domain-specific filters + /// Tuple of paged events and total count + protected async Task<(List Events, int TotalCount)> GetPagedEventsAsync( + DateTime from, + DateTime to, + int pageNumber, + int pageSize, + Func, IQueryable>? additionalFilters = null) + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var query = GetDbSet(context) + .AsNoTracking() + .Where(e => e.Timestamp >= from && e.Timestamp <= to); + + if (additionalFilters != null) + query = additionalFilters(query); + + var totalCount = await query.CountAsync(); + + var events = await query + .OrderByDescending(e => e.Timestamp) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (events, totalCount); + } + + /// + /// Executes a query with time-range filtering and optional domain-specific filters, + /// returning all matching events materialized to a list. Useful for summary aggregation. + /// + /// Start date (inclusive) + /// End date (inclusive) + /// Optional function to apply domain-specific filters + /// List of matching events + protected async Task> GetFilteredEventsAsync( + DateTime from, + DateTime to, + Func, IQueryable>? additionalFilters = null) + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var query = GetDbSet(context) + .AsNoTracking() + .Where(e => e.Timestamp >= from && e.Timestamp <= to); + + if (additionalFilters != null) + query = additionalFilters(query); + + return await query.ToListAsync(); + } + + /// + /// Executes an arbitrary query against the DbContext with automatic scope management. + /// Use for domain-specific queries that don't fit the paginated/filtered patterns. + /// + /// The query result type + /// Function that executes the query against the context + /// The query result + protected async Task ExecuteQueryAsync( + Func> queryFunc) + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + return await queryFunc(context); + } + + #endregion + + #region Private Methods + + /// + /// Timer callback for periodic flushing. + /// + private void FlushTimerCallback(object? state) + { + _ = Task.Run(async () => + { + try { await FlushEventsInternalAsync(); } + catch (Exception ex) { _logger.LogError(ex, "Unhandled error during {EntityName} audit timer flush", EntityName); } + }); + } + + /// + /// Internal flush implementation with optional waiting. + /// + /// If true, waits for semaphore. If false, returns immediately if already flushing. + private async Task FlushEventsInternalAsync(bool wait = false) + { + var timeout = wait ? Timeout.InfiniteTimeSpan : TimeSpan.Zero; + + if (!await _flushSemaphore.WaitAsync(timeout)) + return; // Already flushing and not waiting + + try + { + var events = new List(); + + // Dequeue up to BatchSize events + while (events.Count < BatchSize && _eventQueue.TryDequeue(out var auditEvent)) + { + events.Add(auditEvent); + } + + if (events.Count == 0) + return; + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + await GetDbSet(context).AddRangeAsync(events); + await context.SaveChangesAsync(); + + _logger.LogDebug("Flushed {Count} {EntityName} audit events to database", events.Count, EntityName); + AuditEventsFlushed.WithLabels(EntityName, "success").Inc(events.Count); + AuditQueueDepth.WithLabels(EntityName).Set(_eventQueue.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to flush {EntityName} audit events to database", EntityName); + AuditEventsFlushed.WithLabels(EntityName, "failure").Inc(); + } + finally + { + _flushSemaphore.Release(); + } + } + + #endregion +} diff --git a/Shared/ConduitLLM.Configuration/Services/BatchSpendUpdateService.cs b/Shared/ConduitLLM.Configuration/Services/BatchSpendUpdateService.cs index 14a10d27b..f466e4977 100644 --- a/Shared/ConduitLLM.Configuration/Services/BatchSpendUpdateService.cs +++ b/Shared/ConduitLLM.Configuration/Services/BatchSpendUpdateService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -13,7 +14,7 @@ namespace ConduitLLM.Configuration.Services /// Background service that batches Virtual Key spend updates to reduce database writes /// Provides events for cache invalidation integration /// - public class BatchSpendUpdateService : BackgroundService, IBatchSpendUpdateService + public class BatchSpendUpdateService : BackgroundService, IBatchSpendUpdateService, IAsyncDisposable { private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILogger _logger; @@ -25,7 +26,8 @@ public class BatchSpendUpdateService : BackgroundService, IBatchSpendUpdateServi private readonly TimeSpan _flushInterval; private readonly TimeSpan _redisTtl; private readonly string _redisKeyPrefix = "pending_spend:group:"; - + private readonly ConcurrentQueue<(int VirtualKeyId, decimal Cost)> _fallbackQueue = new(); + /// /// Event raised after successful batch spend updates with the key hashes that were updated /// Allows external cache invalidation without tight coupling @@ -80,89 +82,60 @@ public BatchSpendUpdateService( private CancellationTokenSource? _cancellationTokenSource; /// - /// Add a spend update to the batch queue + /// Queues a spend update to Redis for batch processing. + /// Throws on failure so the caller can fall back to alternative paths. /// /// Virtual Key ID to update /// Cost to add to the current spend - public void QueueSpendUpdate(int virtualKeyId, decimal cost) + public async Task QueueSpendUpdateAsync(int virtualKeyId, decimal cost) { - // Fire and forget pattern for non-blocking updates - _ = Task.Run(async () => + // Check circuit breaker if available + if (_circuitBreaker?.IsOpen == true) { - try - { - // Check circuit breaker if available - if (_circuitBreaker?.IsOpen == true) - { - _logger.LogWarning("Redis circuit breaker is open. Skipping spend update for Virtual Key {VirtualKeyId}", virtualKeyId); - throw new RedisCircuitBreakerOpenException( - "Cannot update spend - Redis circuit breaker is open", - CircuitState.Open); - } + throw new RedisCircuitBreakerOpenException( + "Cannot update spend - Redis circuit breaker is open", + CircuitState.Open); + } - // Need to get the group ID for this key - using var scope = _serviceScopeFactory.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var virtualKey = await context.VirtualKeys - .Where(vk => vk.Id == virtualKeyId) - .Select(vk => new { vk.VirtualKeyGroupId }) - .FirstOrDefaultAsync(); - - if (virtualKey == null) - { - _logger.LogWarning("Virtual Key {VirtualKeyId} not found for spend update", virtualKeyId); - await _alertingService.SendCriticalAlertAsync( - $"Virtual Key {virtualKeyId} not found for spend update", - virtualKeyId); - return; - } + // Need to get the group ID for this key + using var scope = _serviceScopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); - // Execute Redis operations through circuit breaker if available - if (_circuitBreaker != null) - { - await _circuitBreaker.ExecuteAsync(async () => - { - await PerformRedisUpdate(virtualKeyId, virtualKey.VirtualKeyGroupId, cost); - }); - } - else - { - await PerformRedisUpdate(virtualKeyId, virtualKey.VirtualKeyGroupId, cost); - } - - _logger.LogDebug("Queued spend update to Redis for Virtual Key {VirtualKeyId} (Group {GroupId}): {Cost:C}", - virtualKeyId, virtualKey.VirtualKeyGroupId, cost); - } - catch (RedisCircuitBreakerOpenException ex) - { - _logger.LogError(ex, "Redis circuit breaker prevented spend update for Virtual Key {VirtualKeyId}", virtualKeyId); - - // Don't alert for circuit breaker - it's already handling the issue - throw new BillingSystemException( - $"Unable to process billing update. Redis service is currently unavailable.", - virtualKeyId, - BillingSystemException.ErrorCodes.ServiceUnavailable, - ex); - } - catch (Exception ex) + var virtualKey = await context.VirtualKeys + .Where(vk => vk.Id == virtualKeyId) + .Select(vk => new { vk.VirtualKeyGroupId }) + .FirstOrDefaultAsync(); + + if (virtualKey == null) + { + _logger.LogWarning("Virtual Key {VirtualKeyId} not found for spend update", virtualKeyId); + return; + } + + // Execute Redis operations through circuit breaker if available + if (_circuitBreaker != null) + { + await _circuitBreaker.ExecuteAsync(async () => { - _logger.LogError(ex, "Failed to queue spend update to Redis for Virtual Key {VirtualKeyId}", virtualKeyId); - - // Send critical alert - await _alertingService.SendCriticalAlertAsync( - $"Failed to queue spend update to Redis for Virtual Key {virtualKeyId}: {ex.Message}", - virtualKeyId, - new { error = ex.GetType().Name, cost = cost }); - - // Re-throw as BillingSystemException to prevent silent failures - throw new BillingSystemException( - $"Unable to process billing update for Virtual Key {virtualKeyId}. Service temporarily unavailable.", - virtualKeyId, - BillingSystemException.ErrorCodes.RedisUpdateFailed, - ex); - } - }); + await PerformRedisUpdate(virtualKeyId, virtualKey.VirtualKeyGroupId, cost); + }); + } + else + { + await PerformRedisUpdate(virtualKeyId, virtualKey.VirtualKeyGroupId, cost); + } + + _logger.LogDebug("Queued spend update to Redis for Virtual Key {VirtualKeyId} (Group {GroupId}): {Cost:C}", + virtualKeyId, virtualKey.VirtualKeyGroupId, cost); + } + + /// + public void QueueFallbackUpdate(int virtualKeyId, decimal cost) + { + _fallbackQueue.Enqueue((virtualKeyId, cost)); + _logger.LogWarning( + "Spend update for Virtual Key {VirtualKeyId} ({Cost:C}) queued to in-memory fallback. Will be flushed on next cycle.", + virtualKeyId, cost); } private async Task PerformRedisUpdate(int virtualKeyId, int groupId, decimal cost) @@ -228,11 +201,14 @@ public async Task FlushPendingUpdatesAsync() var pattern = $"{_redisKeyPrefix}*"; var keys = server.Keys(pattern: pattern).ToList(); - if (keys.Count() == 0) + if (!keys.Any()) { + _logger.LogDebug("No pending spend updates to flush"); return 0; } - + + _logger.LogDebug("Flushing {PendingCount} pending spend update keys from Redis", keys.Count); + // Get and delete all values atomically var groupUpdates = new Dictionary(); var keyUsagePattern = "key_usage:group:*"; @@ -242,9 +218,8 @@ public async Task FlushPendingUpdatesAsync() // Process group spend updates foreach (var key in keys) { - var keyString = key.ToString(); - var groupId = int.Parse(keyString.Substring(_redisKeyPrefix.Length)); - + var groupId = ParseGroupIdFromKey(key.ToString()); + // Get and delete atomically var value = await db.StringGetDeleteAsync(key); if (value.HasValue && double.TryParse(value.ToString(), out var cost)) @@ -254,23 +229,9 @@ public async Task FlushPendingUpdatesAsync() } // Process key usage data - foreach (var key in keyUsageKeys) - { - var keyString = key.ToString(); - var parts = keyString.Split(':'); - if (parts.Length == 5 && int.TryParse(parts[2], out var groupId) && int.TryParse(parts[4], out var keyId)) - { - var value = await db.StringGetDeleteAsync(key); - if (value.HasValue && double.TryParse(value.ToString(), out var cost)) - { - if (!keyUsageByGroup.ContainsKey(groupId)) - keyUsageByGroup[groupId] = new Dictionary(); - keyUsageByGroup[groupId][keyId] = (decimal)cost; - } - } - } + await ParseKeyUsageData(keyUsageKeys, db, keyUsageByGroup); - if (groupUpdates.Count() == 0) + if (!groupUpdates.Any()) { return 0; } @@ -281,51 +242,93 @@ public async Task FlushPendingUpdatesAsync() // Process each group var updatedKeyHashes = new List(); + var flushStopwatch = System.Diagnostics.Stopwatch.StartNew(); + var processedCount = 0; foreach (var (groupId, totalCost) in groupUpdates) { // Create a description that includes which keys were used - var description = "API usage"; - if (keyUsageByGroup.ContainsKey(groupId)) - { - var keyIds = keyUsageByGroup[groupId].Keys.ToList(); - if (keyIds.Count() == 1) - { - description = $"API usage by virtual key #{keyIds[0]}"; - } - else - { - description = $"API usage by {keyIds.Count()} virtual keys"; - } - } + var description = BuildUsageDescription(groupId, keyUsageByGroup); // Update group balance with transaction details // This already creates a transaction record with the correct BalanceAfter var newBalance = await groupRepository.AdjustBalanceAsync( - groupId, + groupId, -totalCost, description, "System" // Initiated by system batch process ); - + + processedCount++; + _logger.LogDebug( + "Batch flush: updated group {GroupId} โ€” deducted {Cost:C}, new balance: {NewBalance:C} ({Processed}/{Total})", + groupId, totalCost, newBalance, processedCount, groupUpdates.Count); + // Note: We don't need to create additional transaction records here // because AdjustBalanceAsync already creates one with the correct balance. // The individual key usage tracking is already handled in the description. - + // Get keys in this group for cache invalidation var groupKeys = await context.VirtualKeys + .AsNoTracking() .Where(vk => vk.VirtualKeyGroupId == groupId) .Select(vk => new { vk.Id, vk.KeyHash }) .ToListAsync(); - + updatedKeyHashes.AddRange(groupKeys.Select(k => k.KeyHash)); } - - _logger.LogInformation("Batch updated spend for {Count} groups", groupUpdates.Count()); + flushStopwatch.Stop(); + var totalSpend = groupUpdates.Values.Sum(); + _logger.LogInformation( + "Batch flush completed: {GroupCount} groups, total deducted: {TotalSpend:C}, affected keys: {KeyCount}, elapsed: {ElapsedMs}ms", + groupUpdates.Count, totalSpend, updatedKeyHashes.Count, flushStopwatch.ElapsedMilliseconds); + + // Drain in-memory fallback queue + var fallbackCount = 0; + while (_fallbackQueue.TryDequeue(out var fallbackItem)) + { + try + { + var fallbackKey = await context.VirtualKeys + .Where(vk => vk.Id == fallbackItem.VirtualKeyId) + .Select(vk => new { vk.VirtualKeyGroupId, vk.KeyHash }) + .FirstOrDefaultAsync(); + + if (fallbackKey != null) + { + await groupRepository.AdjustBalanceAsync( + fallbackKey.VirtualKeyGroupId, + -fallbackItem.Cost, + $"API usage by virtual key #{fallbackItem.VirtualKeyId} (recovered from fallback queue)", + "System"); + updatedKeyHashes.Add(fallbackKey.KeyHash); + fallbackCount++; + } + else + { + _logger.LogWarning("Virtual Key {VirtualKeyId} from fallback queue not found โ€” spend update lost", + fallbackItem.VirtualKeyId); + } + } + catch (Exception fallbackEx) + { + _logger.LogError(fallbackEx, + "Failed to process fallback spend update for Virtual Key {VirtualKeyId}. Re-queuing.", + fallbackItem.VirtualKeyId); + // Re-queue for the next flush cycle + _fallbackQueue.Enqueue(fallbackItem); + break; // Stop processing fallback queue on error to avoid infinite loop + } + } + + if (fallbackCount > 0) + { + _logger.LogInformation("Recovered {Count} spend updates from in-memory fallback queue", fallbackCount); + } // Raise event for cache invalidation (if any subscribers) - if (updatedKeyHashes.Count() > 0 && SpendUpdatesCompleted != null) + if (updatedKeyHashes.Any() && SpendUpdatesCompleted != null) { try { @@ -348,6 +351,68 @@ public async Task FlushPendingUpdatesAsync() } } + /// + /// Parses a group ID from a Redis key string by stripping the key prefix. + /// + /// The full Redis key string (e.g., "pending_spend:group:42") + /// The parsed group ID + private int ParseGroupIdFromKey(string keyString) + { + return int.Parse(keyString.Substring(_redisKeyPrefix.Length)); + } + + /// + /// Parses key usage data from Redis, reading and deleting each key atomically, + /// and populates the keyUsageByGroup dictionary. + /// + /// List of Redis keys matching the key_usage pattern + /// The Redis database instance + /// Dictionary to populate with group ID -> (key ID -> cost) mappings + private async Task ParseKeyUsageData( + List keyUsageKeys, + StackExchange.Redis.IDatabase db, + Dictionary> keyUsageByGroup) + { + foreach (var key in keyUsageKeys) + { + var keyString = key.ToString(); + var parts = keyString.Split(':'); + if (parts.Length == 5 && int.TryParse(parts[2], out var groupId) && int.TryParse(parts[4], out var keyId)) + { + var value = await db.StringGetDeleteAsync(key); + if (value.HasValue && double.TryParse(value.ToString(), out var cost)) + { + if (!keyUsageByGroup.ContainsKey(groupId)) + keyUsageByGroup[groupId] = new Dictionary(); + keyUsageByGroup[groupId][keyId] = (decimal)cost; + } + } + } + } + + /// + /// Builds a human-readable description of API usage for a given group, + /// including which virtual keys contributed to the spend. + /// + /// The group ID to build the description for + /// Dictionary of group ID -> (key ID -> cost) mappings + /// A description string such as "API usage by virtual key #5" + private static string BuildUsageDescription(int groupId, Dictionary> keyUsageByGroup) + { + if (!keyUsageByGroup.ContainsKey(groupId)) + { + return "API usage"; + } + + var keyIds = keyUsageByGroup[groupId].Keys.ToList(); + if (keyIds.Count == 1) + { + return $"API usage by virtual key #{keyIds[0]}"; + } + + return $"API usage by {keyIds.Count} virtual keys"; + } + /// /// Timer callback for periodic flushing /// @@ -413,17 +478,49 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } - _logger.LogInformation("BatchSpendUpdateService stopping"); + // Final flush before stopping + try + { + var finalCount = await FlushPendingUpdatesAsync(); + if (finalCount > 0) + { + _logger.LogInformation("Flushed {Count} pending updates during shutdown", finalCount); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error flushing pending updates during shutdown"); + } + + _logger.LogInformation("BatchSpendUpdateService stopped"); + } + + /// + /// Async cleanup - preferred over Dispose() to avoid sync-over-async. + /// + public async ValueTask DisposeAsync() + { + await _flushTimer.DisposeAsync(); + + try + { + await FlushPendingUpdatesAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error flushing pending updates during async disposal"); + } + + base.Dispose(); } /// - /// Cleanup resources + /// Sync cleanup fallback. /// public override void Dispose() { _flushTimer?.Dispose(); - - // Try to flush any remaining updates on shutdown + try { FlushPendingUpdatesAsync().GetAwaiter().GetResult(); @@ -432,7 +529,7 @@ public override void Dispose() { _logger.LogError(ex, "Error flushing pending updates during service disposal"); } - + base.Dispose(); } diff --git a/Shared/ConduitLLM.Configuration/Services/BillingAuditService.cs b/Shared/ConduitLLM.Configuration/Services/BillingAuditService.cs index 1cf52528e..27b164ae4 100644 --- a/Shared/ConduitLLM.Configuration/Services/BillingAuditService.cs +++ b/Shared/ConduitLLM.Configuration/Services/BillingAuditService.cs @@ -1,176 +1,139 @@ -using System.Collections.Concurrent; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -namespace ConduitLLM.Configuration.Services +namespace ConduitLLM.Configuration.Services; + +/// +/// Service for auditing billing events with batch writing and async processing. +/// +public class BillingAuditService : BatchAuditServiceBase, IBillingAuditService { /// - /// Service for auditing billing events with batch writing and async processing + /// Creates a new instance of the BillingAuditService. /// - public class BillingAuditService : IBillingAuditService, IHostedService, IDisposable + /// Service provider for creating scoped DbContexts + /// Logger instance + public BillingAuditService( + IServiceProvider serviceProvider, + ILogger logger) + : base(serviceProvider, logger) { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly ConcurrentQueue _eventQueue; - private readonly Timer _flushTimer; - private readonly SemaphoreSlim _flushSemaphore; - private bool _disposed; + } - private const int BatchSize = 100; - private const int FlushIntervalSeconds = 10; + #region Template Method Implementations - public BillingAuditService( - IServiceProvider serviceProvider, - ILogger logger) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _eventQueue = new ConcurrentQueue(); - _flushSemaphore = new SemaphoreSlim(1, 1); - _flushTimer = new Timer(FlushEvents, null, Timeout.Infinite, Timeout.Infinite); - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.BillingAuditEvents; - /// - public async Task LogBillingEventAsync(BillingAuditEvent auditEvent) - { - if (auditEvent == null) - throw new ArgumentNullException(nameof(auditEvent)); + /// + protected override string EntityName => "Billing"; - // Queue the event for batch processing - _eventQueue.Enqueue(auditEvent); + #endregion - // If we've reached the batch size, flush immediately and wait for it - if (_eventQueue.Count >= BatchSize) - { - await FlushEventsAsync(wait: true); - } - } + #region IBillingAuditService Implementation (Wrapper Methods) - /// - public void LogBillingEvent(BillingAuditEvent auditEvent) - { - if (auditEvent == null) - return; + /// + public Task LogBillingEventAsync(BillingAuditEvent auditEvent) + => LogEventAsync(auditEvent); - // Fire and forget - queue the event - _eventQueue.Enqueue(auditEvent); + /// + public void LogBillingEvent(BillingAuditEvent auditEvent) + => LogEvent(auditEvent); - // Trigger flush if batch size reached - if (_eventQueue.Count >= BatchSize) - { - _ = Task.Run(async () => await FlushEventsAsync()); - } - } + /// + public Task CleanupOldAuditEventsAsync() + => CleanupOldEventsAsync(); - /// - public async Task<(List Events, int TotalCount)> GetAuditEventsAsync( - DateTime from, - DateTime to, - BillingAuditEventType? eventType = null, - int? virtualKeyId = null, - int pageNumber = 1, - int pageSize = 100) - { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); + #endregion - var query = context.BillingAuditEvents - .AsNoTracking() - .Where(e => e.Timestamp >= from && e.Timestamp <= to); + #region Domain-Specific Query Methods + /// + public async Task<(List Events, int TotalCount)> GetAuditEventsAsync( + DateTime from, + DateTime to, + BillingAuditEventType? eventType = null, + int? virtualKeyId = null, + int pageNumber = 1, + int pageSize = 100) + { + return await GetPagedEventsAsync(from, to, pageNumber, pageSize, query => + { if (eventType.HasValue) query = query.Where(e => e.EventType == eventType.Value); - if (virtualKeyId.HasValue) query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); + return query; + }); + } - var totalCount = await query.CountAsync(); - - var events = await query - .OrderByDescending(e => e.Timestamp) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); - - return (events, totalCount); - } - - /// - public async Task GetAuditSummaryAsync( - DateTime from, - DateTime to, - int? virtualKeyId = null) + /// + public async Task GetAuditSummaryAsync( + DateTime from, + DateTime to, + int? virtualKeyId = null) + { + var events = await GetFilteredEventsAsync(from, to, query => { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var query = context.BillingAuditEvents - .AsNoTracking() - .Where(e => e.Timestamp >= from && e.Timestamp <= to); - if (virtualKeyId.HasValue) query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); + return query; + }); - var events = await query.ToListAsync(); - - var summary = new BillingAuditSummary - { - TotalEvents = events.Count, - SuccessfulBillings = events.Count(e => e.EventType == BillingAuditEventType.UsageTracked), - ZeroCostSkipped = events.Count(e => e.EventType == BillingAuditEventType.ZeroCostSkipped), - EstimatedUsages = events.Count(e => e.EventType == BillingAuditEventType.UsageEstimated), - FailedUpdates = events.Count(e => e.EventType == BillingAuditEventType.SpendUpdateFailed), - ErrorResponsesSkipped = events.Count(e => e.EventType == BillingAuditEventType.ErrorResponseSkipped), - MissingUsageData = events.Count(e => e.EventType == BillingAuditEventType.MissingUsageData), - TotalBilledAmount = events - .Where(e => e.EventType == BillingAuditEventType.UsageTracked && e.CalculatedCost.HasValue) - .Sum(e => e.CalculatedCost!.Value), - PotentialRevenueLoss = events - .Where(e => e.EventType != BillingAuditEventType.UsageTracked && - e.EventType != BillingAuditEventType.ErrorResponseSkipped && - e.CalculatedCost.HasValue) - .Sum(e => e.CalculatedCost!.Value) - }; - - // Event type breakdown - summary.EventTypeBreakdown = events - .GroupBy(e => e.EventType) - .ToDictionary(g => g.Key, g => (long)g.Count()); - - // Provider type breakdown - summary.ProviderTypeBreakdown = events - .Where(e => !string.IsNullOrEmpty(e.ProviderType)) - .GroupBy(e => e.ProviderType!) - .ToDictionary(g => g.Key, g => (long)g.Count()); - - return summary; - } - - /// - public async Task GetPotentialRevenueLossAsync(DateTime from, DateTime to) + var summary = new BillingAuditSummary { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); + TotalEvents = events.Count, + SuccessfulBillings = events.Count(e => e.EventType == BillingAuditEventType.UsageTracked), + ZeroCostSkipped = events.Count(e => e.EventType == BillingAuditEventType.ZeroCostSkipped), + EstimatedUsages = events.Count(e => e.EventType == BillingAuditEventType.UsageEstimated), + FailedUpdates = events.Count(e => e.EventType == BillingAuditEventType.SpendUpdateFailed), + ErrorResponsesSkipped = events.Count(e => e.EventType == BillingAuditEventType.ErrorResponseSkipped), + MissingUsageData = events.Count(e => e.EventType == BillingAuditEventType.MissingUsageData), + TotalBilledAmount = events + .Where(e => e.EventType == BillingAuditEventType.UsageTracked && e.CalculatedCost.HasValue) + .Sum(e => e.CalculatedCost!.Value), + PotentialRevenueLoss = events + .Where(e => e.EventType != BillingAuditEventType.UsageTracked && + e.EventType != BillingAuditEventType.ErrorResponseSkipped && + e.CalculatedCost.HasValue) + .Sum(e => e.CalculatedCost!.Value) + }; + + // Event type breakdown + summary.EventTypeBreakdown = events + .GroupBy(e => e.EventType) + .ToDictionary(g => g.Key, g => (long)g.Count()); + + // Provider type breakdown + summary.ProviderTypeBreakdown = events + .Where(e => !string.IsNullOrEmpty(e.ProviderType)) + .GroupBy(e => e.ProviderType!) + .ToDictionary(g => g.Key, g => (long)g.Count()); + + return summary; + } - return await context.BillingAuditEvents + /// + public async Task GetPotentialRevenueLossAsync(DateTime from, DateTime to) + { + return await ExecuteQueryAsync(context => + context.BillingAuditEvents .AsNoTracking() .Where(e => e.Timestamp >= from && e.Timestamp <= to) .Where(e => e.EventType != BillingAuditEventType.UsageTracked) .Where(e => e.EventType != BillingAuditEventType.ErrorResponseSkipped) .Where(e => e.CalculatedCost.HasValue) - .SumAsync(e => e.CalculatedCost ?? 0); - } + .SumAsync(e => e.CalculatedCost ?? 0)); + } - /// - public async Task> DetectAnomaliesAsync(DateTime from, DateTime to) + /// + public async Task> DetectAnomaliesAsync(DateTime from, DateTime to) + { + return await ExecuteQueryAsync(async context => { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - var anomalies = new List(); // Check for high failure rate @@ -249,204 +212,8 @@ public async Task> DetectAnomaliesAsync(DateTime from, Date } return anomalies; - } - - /// - /// Flushes queued events to the database - /// - /// If true, wait for semaphore. If false, return immediately if semaphore is busy. - private async Task FlushEventsAsync(bool wait = false) - { - var timeout = wait ? Timeout.InfiniteTimeSpan : TimeSpan.Zero; - if (!await _flushSemaphore.WaitAsync(timeout)) - return; // Already flushing and not waiting - - try - { - var events = new List(); - - // Dequeue up to BatchSize events - while (events.Count < BatchSize && _eventQueue.TryDequeue(out var auditEvent)) - { - events.Add(auditEvent); - } - - if (events.Count == 0) - return; - - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - await context.BillingAuditEvents.AddRangeAsync(events); - await context.SaveChangesAsync(); - - _logger.LogDebug("Flushed {Count} billing audit events to database", events.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to flush billing audit events to database"); - } - finally - { - _flushSemaphore.Release(); - } - } - - /// - /// Timer callback for periodic flushing - /// - private void FlushEvents(object? state) - { - _ = Task.Run(async () => await FlushEventsAsync()); - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Starting BillingAuditService with batch size {BatchSize} and flush interval {FlushInterval}s", - BatchSize, FlushIntervalSeconds); - - // Start the flush timer - _flushTimer.Change(TimeSpan.FromSeconds(FlushIntervalSeconds), TimeSpan.FromSeconds(FlushIntervalSeconds)); - - // Schedule daily cleanup of old audit events - _ = Task.Run(async () => await ScheduleDataRetentionAsync(cancellationToken), cancellationToken); - - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Stopping BillingAuditService, flushing remaining events..."); - - // Stop the timer - _flushTimer?.Change(Timeout.Infinite, 0); - - // Force flush any remaining events - wait for semaphore to ensure all events are flushed - await _flushSemaphore.WaitAsync(cancellationToken); - try - { - var events = new List(); - - // Dequeue ALL remaining events - while (_eventQueue.TryDequeue(out var auditEvent)) - { - events.Add(auditEvent); - } - - if (events.Count > 0) - { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - await context.BillingAuditEvents.AddRangeAsync(events); - await context.SaveChangesAsync(); - - _logger.LogDebug("Final flush of {Count} billing audit events to database", events.Count); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to flush remaining billing audit events to database"); - } - finally - { - _flushSemaphore.Release(); - } - } - - /// - public void Dispose() - { - if (_disposed) - return; - - _flushTimer?.Dispose(); - _flushSemaphore?.Dispose(); - _disposed = true; - } - - /// - /// Schedules periodic data retention cleanup - /// - private async Task ScheduleDataRetentionAsync(CancellationToken cancellationToken) - { - // Wait for initial delay before first cleanup - await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); - - while (!cancellationToken.IsCancellationRequested) - { - try - { - await CleanupOldAuditEventsAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during audit event cleanup"); - } - - // Run cleanup daily - await Task.Delay(TimeSpan.FromDays(1), cancellationToken); - } - } - - /// - /// Removes audit events older than retention period - /// - public async Task CleanupOldAuditEventsAsync() - { - const int RetentionDays = 90; // Keep audit events for 90 days - const int BatchSize = 1000; // Delete in batches to avoid locking - - _logger.LogInformation("Starting cleanup of audit events older than {RetentionDays} days", RetentionDays); - - try - { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var cutoffDate = DateTime.UtcNow.AddDays(-RetentionDays); - int totalDeleted = 0; - int batchDeleted; - - do - { - // Get a batch of old events to delete - var oldEvents = await context.BillingAuditEvents - .Where(e => e.Timestamp < cutoffDate) - .OrderBy(e => e.Timestamp) - .Take(BatchSize) - .ToListAsync(); - - if (oldEvents.Count == 0) - break; - - context.BillingAuditEvents.RemoveRange(oldEvents); - await context.SaveChangesAsync(); - - batchDeleted = oldEvents.Count; - totalDeleted += batchDeleted; - - _logger.LogDebug("Deleted {BatchCount} old audit events", batchDeleted); - - // Brief pause between batches to reduce database load - if (batchDeleted == BatchSize) - await Task.Delay(100); - - } while (batchDeleted == BatchSize); - - if (totalDeleted > 0) - { - _logger.LogInformation("Cleanup completed: Deleted {TotalDeleted} audit events older than {CutoffDate}", - totalDeleted, cutoffDate); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to cleanup old audit events"); - throw; - } - } + }); } -} \ No newline at end of file + + #endregion +} diff --git a/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.Audit.cs b/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.Audit.cs deleted file mode 100644 index 800a4847b..000000000 --- a/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.Audit.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Events; -using ConduitLLM.Configuration.Models; - -namespace ConduitLLM.Configuration.Services -{ - /// - /// Cache configuration service - Audit and rollback functionality - /// - public partial class CacheConfigurationService - { - public async Task> GetAuditHistoryAsync( - string region, - int limit = 100, - CancellationToken cancellationToken = default) - { - return await _dbContext.CacheConfigurationAudits - .Where(a => a.Region == region) - .OrderByDescending(a => a.ChangedAt) - .Take(limit) - .ToListAsync(cancellationToken); - } - - public async Task RollbackConfigurationAsync( - string region, - int auditId, - string rolledBackBy, - CancellationToken cancellationToken = default) - { - var audit = await _dbContext.CacheConfigurationAudits - .Where(a => a.Id == auditId && a.Region == region) - .FirstOrDefaultAsync(cancellationToken); - - if (audit == null) - { - throw new InvalidOperationException($"Audit entry {auditId} not found for region {region}"); - } - - if (string.IsNullOrEmpty(audit.OldConfigJson)) - { - throw new InvalidOperationException($"No previous configuration available to rollback to"); - } - - var configToRestore = JsonSerializer.Deserialize(audit.OldConfigJson); - if (configToRestore == null) - { - throw new InvalidOperationException($"Failed to deserialize previous configuration"); - } - - // Update configuration with rollback flag - var result = await UpdateConfigurationAsync( - region, - configToRestore, - rolledBackBy, - $"Rollback to configuration from {audit.ChangedAt:yyyy-MM-dd HH:mm:ss}", - cancellationToken); - - // Publish rollback event - await _publishEndpoint.Publish(new CacheConfigurationChangedEvent - { - Region = region, - Action = "RolledBack", - NewConfig = configToRestore, - ChangedBy = rolledBackBy, - ChangedAt = DateTime.UtcNow, - Reason = $"Rollback to audit entry {auditId}", - IsRollback = true, - ChangeSource = "API" - }, cancellationToken); - - return result; - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.Helpers.cs b/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.Helpers.cs deleted file mode 100644 index e013c0747..000000000 --- a/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.Helpers.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Models; - -namespace ConduitLLM.Configuration.Services -{ - /// - /// Cache configuration service - Helper methods and environment configuration - /// - public partial class CacheConfigurationService - { - public async Task ApplyEnvironmentConfigurationsAsync(CancellationToken cancellationToken = default) - { - var applied = 0; - - foreach (string region in CacheRegions.All) - { - var envKey = $"CONDUIT_CACHE_{region.ToString().ToUpperInvariant()}_"; - var envConfig = new Dictionary(); - - // Check for environment variables - var enabled = Environment.GetEnvironmentVariable($"{envKey}ENABLED"); - if (!string.IsNullOrEmpty(enabled)) - { - envConfig["Enabled"] = enabled; - } - - var ttl = Environment.GetEnvironmentVariable($"{envKey}TTL"); - if (!string.IsNullOrEmpty(ttl)) - { - envConfig["DefaultTTL"] = ttl; - } - - var maxTtl = Environment.GetEnvironmentVariable($"{envKey}MAX_TTL"); - if (!string.IsNullOrEmpty(maxTtl)) - { - envConfig["MaxTTL"] = maxTtl; - } - - if (envConfig.Count() > 0) - { - try - { - var currentConfig = await GetConfigurationAsync(region, cancellationToken); - if (currentConfig == null) - { - currentConfig = new CacheRegionConfig { Region = region }; - } - - // Apply environment overrides - if (envConfig.TryGetValue("Enabled", out var enabledStr) && bool.TryParse(enabledStr, out var enabledValue)) - { - currentConfig.Enabled = enabledValue; - } - - if (envConfig.TryGetValue("DefaultTTL", out var ttlStr) && int.TryParse(ttlStr, out var ttlSeconds)) - { - currentConfig.DefaultTTL = TimeSpan.FromSeconds(ttlSeconds); - } - - if (envConfig.TryGetValue("MaxTTL", out var maxTtlStr) && int.TryParse(maxTtlStr, out var maxTtlSeconds)) - { - currentConfig.MaxTTL = TimeSpan.FromSeconds(maxTtlSeconds); - } - - // Check if configuration exists in database (IsActive filter applied automatically via named query filter) - var exists = await _dbContext.CacheConfigurations - .AnyAsync(c => c.Region == region, cancellationToken); - - if (exists) - { - await UpdateConfigurationAsync(region, currentConfig, "System", "Applied from environment variables", cancellationToken); - } - else - { - await CreateConfigurationAsync(region, currentConfig, "System", cancellationToken); - } - - applied++; - _logger.LogInformation("Applied environment configuration for cache region {Region}", region); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to apply environment configuration for cache region {Region}", region); - } - } - } - - if (applied > 0) - { - _logger.LogInformation("Applied {Count} cache configurations from environment variables", applied); - } - } - - private CacheRegionConfig MapEntityToConfig(CacheConfiguration entity) - { - var config = new CacheRegionConfig - { - Region = entity.Region, - Enabled = entity.Enabled, - Priority = entity.Priority, - EvictionPolicy = entity.EvictionPolicy, - UseMemoryCache = entity.UseMemoryCache, - UseDistributedCache = entity.UseDistributedCache, - EnableDetailedStats = entity.EnableDetailedStats, - EnableCompression = entity.EnableCompression - }; - - if (entity.DefaultTtlSeconds.HasValue) - { - config.DefaultTTL = TimeSpan.FromSeconds(entity.DefaultTtlSeconds.Value); - } - - if (entity.MaxTtlSeconds.HasValue) - { - config.MaxTTL = TimeSpan.FromSeconds(entity.MaxTtlSeconds.Value); - } - - if (entity.MaxEntries.HasValue) - { - config.MaxEntries = entity.MaxEntries.Value; - } - - if (entity.MaxMemoryBytes.HasValue) - { - config.MaxMemoryBytes = entity.MaxMemoryBytes.Value; - } - - if (entity.CompressionThresholdBytes.HasValue) - { - config.CompressionThresholdBytes = entity.CompressionThresholdBytes.Value; - } - - // Parse extended config if available - if (!string.IsNullOrEmpty(entity.ExtendedConfig)) - { - try - { - var extended = JsonSerializer.Deserialize>(entity.ExtendedConfig); - if (extended != null) - { - config.ExtendedProperties = extended; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to parse extended config for region {Region}", entity.Region); - } - } - - return config; - } - - private void UpdateEntityFromConfig(CacheConfiguration entity, CacheRegionConfig config) - { - entity.Enabled = config.Enabled; - entity.Priority = config.Priority; - entity.EvictionPolicy = config.EvictionPolicy; - entity.UseMemoryCache = config.UseMemoryCache; - entity.UseDistributedCache = config.UseDistributedCache; - entity.EnableDetailedStats = config.EnableDetailedStats; - entity.EnableCompression = config.EnableCompression; - - entity.DefaultTtlSeconds = config.DefaultTTL?.TotalSeconds > 0 ? (int)config.DefaultTTL.Value.TotalSeconds : null; - entity.MaxTtlSeconds = config.MaxTTL?.TotalSeconds > 0 ? (int)config.MaxTTL.Value.TotalSeconds : null; - entity.MaxEntries = config.MaxEntries; - entity.MaxMemoryBytes = config.MaxMemoryBytes; - entity.CompressionThresholdBytes = config.CompressionThresholdBytes; - - if (config.ExtendedProperties?.Count > 0) - { - entity.ExtendedConfig = JsonSerializer.Serialize(config.ExtendedProperties); - } - } - - private CacheRegionConfig CreateConfigFromSection(string region, IConfigurationSection section) - { - var config = new CacheRegionConfig - { - Region = region, - Enabled = section.GetValue("Enabled", true), - Priority = section.GetValue("Priority", 50), - UseMemoryCache = section.GetValue("UseMemoryCache", true), - UseDistributedCache = section.GetValue("UseDistributedCache", false), - EnableDetailedStats = section.GetValue("EnableDetailedStats", true), - EnableCompression = section.GetValue("EnableCompression", false) - }; - - var ttlSeconds = section.GetValue("DefaultTtlSeconds"); - if (ttlSeconds.HasValue) - { - config.DefaultTTL = TimeSpan.FromSeconds(ttlSeconds.Value); - } - - var maxTtlSeconds = section.GetValue("MaxTtlSeconds"); - if (maxTtlSeconds.HasValue) - { - config.MaxTTL = TimeSpan.FromSeconds(maxTtlSeconds.Value); - } - - config.MaxEntries = section.GetValue("MaxEntries"); - config.MaxMemoryBytes = section.GetValue("MaxMemoryBytes"); - config.CompressionThresholdBytes = section.GetValue("CompressionThresholdBytes"); - - var evictionPolicy = section.GetValue("EvictionPolicy"); - if (!string.IsNullOrEmpty(evictionPolicy)) - { - config.EvictionPolicy = evictionPolicy; - } - - return config; - } - - private async Task CacheConfigAsync(string region, CacheRegionConfig config, CancellationToken cancellationToken) - { - await _lock.WaitAsync(cancellationToken); - try - { - _cache[region] = config; - } - finally - { - _lock.Release(); - } - } - } - - /// - /// Validation result for cache configurations. - /// - public class ValidationResult - { - public bool IsValid { get; set; } - public List Errors { get; } = new(); - - public void AddError(string error) - { - Errors.Add(error); - IsValid = false; - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.cs b/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.cs deleted file mode 100644 index b7ec0f00b..000000000 --- a/Shared/ConduitLLM.Configuration/Services/CacheConfigurationService.cs +++ /dev/null @@ -1,455 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using MassTransit; -using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Events; -using ConduitLLM.Configuration.Models; - -namespace ConduitLLM.Configuration.Services -{ - /// - /// Service for managing cache configurations with dynamic runtime updates. - /// - public interface ICacheConfigurationService - { - /// - /// Gets the configuration for a specific cache region. - /// - Task GetConfigurationAsync(string region, CancellationToken cancellationToken = default); - - /// - /// Gets all active cache configurations. - /// - Task> GetAllConfigurationsAsync(CancellationToken cancellationToken = default); - - /// - /// Updates the configuration for a specific cache region. - /// - Task UpdateConfigurationAsync(string region, CacheRegionConfig config, string changedBy, string? reason = null, CancellationToken cancellationToken = default); - - /// - /// Creates a new configuration for a cache region. - /// - Task CreateConfigurationAsync(string region, CacheRegionConfig config, string createdBy, CancellationToken cancellationToken = default); - - /// - /// Deletes the configuration for a cache region. - /// - Task DeleteConfigurationAsync(string region, string deletedBy, string? reason = null, CancellationToken cancellationToken = default); - - /// - /// Validates a cache configuration. - /// - Task ValidateConfigurationAsync(CacheRegionConfig config, CancellationToken cancellationToken = default); - - /// - /// Gets the audit history for a cache region. - /// - Task> GetAuditHistoryAsync(string region, int limit = 100, CancellationToken cancellationToken = default); - - /// - /// Rolls back to a previous configuration. - /// - Task RollbackConfigurationAsync(string region, int auditId, string rolledBackBy, CancellationToken cancellationToken = default); - - /// - /// Applies configurations from environment variables or config files. - /// - Task ApplyEnvironmentConfigurationsAsync(CancellationToken cancellationToken = default); - } - - /// - /// Implementation of cache configuration service. - /// - public partial class CacheConfigurationService : ICacheConfigurationService - { - private readonly ConduitDbContext _dbContext; - private readonly IPublishEndpoint _publishEndpoint; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly Dictionary _cache = new(); - private readonly SemaphoreSlim _lock = new(1, 1); - - public CacheConfigurationService( - ConduitDbContext dbContext, - IPublishEndpoint publishEndpoint, - IConfiguration configuration, - ILogger logger) - { - _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); - _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint)); - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetConfigurationAsync(string region, CancellationToken cancellationToken = default) - { - // Check memory cache first - await _lock.WaitAsync(cancellationToken); - try - { - if (_cache.TryGetValue(region, out var cached)) - { - return cached; - } - } - finally - { - _lock.Release(); - } - - // Load from database (IsActive filter applied automatically via named query filter) - var entity = await _dbContext.CacheConfigurations - .Where(c => c.Region == region) - .FirstOrDefaultAsync(cancellationToken); - - if (entity == null) - { - // Try to load from configuration - var configSection = _configuration.GetSection($"Cache:Regions:{region}"); - if (configSection.Exists()) - { - var config = CreateConfigFromSection(region, configSection); - await CacheConfigAsync(region, config, cancellationToken); - return config; - } - - return null; - } - - var regionConfig = MapEntityToConfig(entity); - await CacheConfigAsync(region, regionConfig, cancellationToken); - return regionConfig; - } - - public async Task> GetAllConfigurationsAsync(CancellationToken cancellationToken = default) - { - var configs = new Dictionary(); - - // Load all from database (IsActive filter applied automatically via named query filter) - var entities = await _dbContext.CacheConfigurations - .ToListAsync(cancellationToken); - - foreach (var entity in entities) - { - configs[entity.Region] = MapEntityToConfig(entity); - } - - // Load any missing from configuration - foreach (string region in CacheRegions.All) - { - if (!configs.ContainsKey(region)) - { - var configSection = _configuration.GetSection($"Cache:Regions:{region}"); - if (configSection.Exists()) - { - configs[region] = CreateConfigFromSection(region, configSection); - } - } - } - - // Update cache - await _lock.WaitAsync(cancellationToken); - try - { - _cache.Clear(); - foreach (var (region, config) in configs) - { - _cache[region] = config; - } - } - finally - { - _lock.Release(); - } - - return configs; - } - - public async Task UpdateConfigurationAsync( - string region, - CacheRegionConfig config, - string changedBy, - string? reason = null, - CancellationToken cancellationToken = default) - { - // Validate configuration - var validation = await ValidateConfigurationAsync(config, cancellationToken); - if (!validation.IsValid) - { - throw new InvalidOperationException($"Invalid configuration: {string.Join(", ", validation.Errors)}"); - } - - // IsActive filter applied automatically via named query filter - var entity = await _dbContext.CacheConfigurations - .Where(c => c.Region == region) - .FirstOrDefaultAsync(cancellationToken); - - if (entity == null) - { - throw new InvalidOperationException($"No active configuration found for region {region}"); - } - - // Store old config for audit - var oldConfig = MapEntityToConfig(entity); - - // Create audit entry - var audit = new CacheConfigurationAudit - { - Region = region, - Action = "Updated", - OldConfigJson = JsonSerializer.Serialize(oldConfig), - NewConfigJson = JsonSerializer.Serialize(config), - Reason = reason, - ChangedBy = changedBy, - ChangedAt = DateTime.UtcNow, - ChangeSource = "API" - }; - - try - { - // Update entity - UpdateEntityFromConfig(entity, config); - entity.UpdatedAt = DateTime.UtcNow; - entity.UpdatedBy = changedBy; - - _dbContext.CacheConfigurationAudits.Add(audit); - await _dbContext.SaveChangesAsync(cancellationToken); - - audit.Success = true; - - // Update cache - await CacheConfigAsync(region, config, cancellationToken); - - // Publish event - await _publishEndpoint.Publish(new CacheConfigurationChangedEvent - { - Region = region, - Action = "Updated", - OldConfig = oldConfig, - NewConfig = config, - ChangedBy = changedBy, - ChangedAt = DateTime.UtcNow, - Reason = reason, - ChangeSource = "API" - }, cancellationToken); - - _logger.LogInformation("Updated cache configuration for region {Region}", region); - return config; - } - catch (Exception ex) - { - audit.Success = false; - audit.ErrorMessage = ex.Message; - _dbContext.CacheConfigurationAudits.Add(audit); - await _dbContext.SaveChangesAsync(cancellationToken); - throw; - } - } - - public async Task CreateConfigurationAsync( - string region, - CacheRegionConfig config, - string createdBy, - CancellationToken cancellationToken = default) - { - // Validate configuration - var validation = await ValidateConfigurationAsync(config, cancellationToken); - if (!validation.IsValid) - { - throw new InvalidOperationException($"Invalid configuration: {string.Join(", ", validation.Errors)}"); - } - - // Check if already exists (IsActive filter applied automatically via named query filter) - var existing = await _dbContext.CacheConfigurations - .Where(c => c.Region == region) - .FirstOrDefaultAsync(cancellationToken); - - if (existing != null) - { - throw new InvalidOperationException($"Active configuration already exists for region {region}"); - } - - var entity = new CacheConfiguration - { - Region = region, - CreatedBy = createdBy, - UpdatedBy = createdBy, - IsActive = true - }; - - UpdateEntityFromConfig(entity, config); - - // Create audit entry - var audit = new CacheConfigurationAudit - { - Region = region, - Action = "Created", - NewConfigJson = JsonSerializer.Serialize(config), - ChangedBy = createdBy, - ChangedAt = DateTime.UtcNow, - ChangeSource = "API" - }; - - try - { - _dbContext.CacheConfigurations.Add(entity); - _dbContext.CacheConfigurationAudits.Add(audit); - await _dbContext.SaveChangesAsync(cancellationToken); - - audit.Success = true; - - // Update cache - await CacheConfigAsync(region, config, cancellationToken); - - // Publish event - await _publishEndpoint.Publish(new CacheConfigurationChangedEvent - { - Region = region, - Action = "Created", - NewConfig = config, - ChangedBy = createdBy, - ChangedAt = DateTime.UtcNow, - ChangeSource = "API" - }, cancellationToken); - - _logger.LogInformation("Created cache configuration for region {Region}", region); - return config; - } - catch (Exception ex) - { - audit.Success = false; - audit.ErrorMessage = ex.Message; - _dbContext.CacheConfigurationAudits.Add(audit); - await _dbContext.SaveChangesAsync(cancellationToken); - throw; - } - } - - public async Task DeleteConfigurationAsync( - string region, - string deletedBy, - string? reason = null, - CancellationToken cancellationToken = default) - { - // IsActive filter applied automatically via named query filter - var entity = await _dbContext.CacheConfigurations - .Where(c => c.Region == region) - .FirstOrDefaultAsync(cancellationToken); - - if (entity == null) - { - return false; - } - - var oldConfig = MapEntityToConfig(entity); - - // Create audit entry - var audit = new CacheConfigurationAudit - { - Region = region, - Action = "Deleted", - OldConfigJson = JsonSerializer.Serialize(oldConfig), - Reason = reason, - ChangedBy = deletedBy, - ChangedAt = DateTime.UtcNow, - ChangeSource = "API" - }; - - try - { - // Soft delete - entity.IsActive = false; - entity.UpdatedAt = DateTime.UtcNow; - entity.UpdatedBy = deletedBy; - - _dbContext.CacheConfigurationAudits.Add(audit); - await _dbContext.SaveChangesAsync(cancellationToken); - - audit.Success = true; - - // Remove from cache - await _lock.WaitAsync(cancellationToken); - try - { - _cache.Remove(region); - } - finally - { - _lock.Release(); - } - - // Publish event - await _publishEndpoint.Publish(new CacheConfigurationChangedEvent - { - Region = region, - Action = "Deleted", - OldConfig = oldConfig, - ChangedBy = deletedBy, - ChangedAt = DateTime.UtcNow, - Reason = reason, - ChangeSource = "API" - }, cancellationToken); - - _logger.LogInformation("Deleted cache configuration for region {Region}", region); - return true; - } - catch (Exception ex) - { - audit.Success = false; - audit.ErrorMessage = ex.Message; - _dbContext.CacheConfigurationAudits.Add(audit); - await _dbContext.SaveChangesAsync(cancellationToken); - throw; - } - } - - public Task ValidateConfigurationAsync(CacheRegionConfig config, CancellationToken cancellationToken = default) - { - var result = new ValidationResult { IsValid = true }; - - // Validate TTL - if (config.DefaultTTL.HasValue && config.DefaultTTL.Value < TimeSpan.Zero) - { - result.AddError("DefaultTTL cannot be negative"); - } - - if (config.MaxTTL.HasValue && config.MaxTTL.Value < TimeSpan.Zero) - { - result.AddError("MaxTTL cannot be negative"); - } - - if (config.DefaultTTL.HasValue && config.MaxTTL.HasValue && config.DefaultTTL.Value > config.MaxTTL.Value) - { - result.AddError("DefaultTTL cannot be greater than MaxTTL"); - } - - // Validate sizes - if (config.MaxEntries.HasValue && config.MaxEntries.Value <= 0) - { - result.AddError("MaxEntries must be greater than 0"); - } - - if (config.MaxMemoryBytes.HasValue && config.MaxMemoryBytes.Value <= 0) - { - result.AddError("MaxMemoryBytes must be greater than 0"); - } - - // Validate priority - if (config.Priority < 0 || config.Priority > 100) - { - result.AddError("Priority must be between 0 and 100"); - } - - // Validate compression - if (config.EnableCompression && config.CompressionThresholdBytes.HasValue && config.CompressionThresholdBytes.Value <= 0) - { - result.AddError("CompressionThresholdBytes must be greater than 0 when compression is enabled"); - } - - return Task.FromResult(result); - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Configuration/Services/CredentialValidatorBase.cs b/Shared/ConduitLLM.Configuration/Services/CredentialValidatorBase.cs new file mode 100644 index 000000000..2ceb8590b --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Services/CredentialValidatorBase.cs @@ -0,0 +1,138 @@ +using System.Linq.Expressions; +using ConduitLLM.Functions.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Configuration.Services; + +/// +/// Base class for credential validators that share the same validation state machine: +/// add (max count), set-primary (must be enabled), disable (must not be primary), +/// and has-enabled (at least one enabled in group). +/// +public abstract class CredentialValidatorBase where TEntity : class, ICredentialEntity +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly ILogger _logger; + + /// Maximum number of credentials allowed per group. + protected abstract int MaxPerGroup { get; } + + /// Human-readable name for the entity (e.g. "key" or "credential"). + protected abstract string EntityName { get; } + + /// Human-readable name for the group (e.g. "provider" or "provider type"). + protected abstract string GroupName { get; } + + /// Returns the DbSet for this entity type from the given context. + protected abstract DbSet GetDbSet(ConduitDbContext context); + + protected CredentialValidatorBase(IDbContextFactory dbContextFactory, ILogger logger) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Validates that adding a new credential to the group would not exceed the maximum. + /// + protected async Task ValidateAddAsync( + Expression> groupPredicate, + CancellationToken cancellationToken = default) + { + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var currentCount = await GetDbSet(context) + .CountAsync(groupPredicate, cancellationToken); + + if (currentCount >= MaxPerGroup) + { + _logger.LogWarning( + "Credential add rejected: {GroupName} already has {CurrentCount}/{MaxPerGroup} {EntityName}s", + GroupName, currentCount, MaxPerGroup, EntityName); + return ValidationResult.Failure( + $"{GroupName} already has the maximum of {MaxPerGroup} {EntityName}s"); + } + + return ValidationResult.Success(); + } + + /// + /// Validates that the credential can be set as primary (must exist and be enabled). + /// + public async Task ValidateSetPrimaryAsync( + int id, + CancellationToken cancellationToken = default) + { + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var entity = await GetDbSet(context) + .FirstOrDefaultAsync(e => e.Id == id, cancellationToken); + + if (entity == null) + { + _logger.LogWarning("Set-primary rejected: {EntityName} {Id} not found", EntityName, id); + return ValidationResult.Failure($"{EntityName} not found"); + } + + if (!entity.IsEnabled) + { + _logger.LogWarning("Set-primary rejected: {EntityName} {Id} is disabled", EntityName, id); + return ValidationResult.Failure($"Cannot set a disabled {EntityName} as primary"); + } + + return ValidationResult.Success(); + } + + /// + /// Validates that the credential can be disabled (must exist and not be primary). + /// + public async Task ValidateDisableAsync( + int id, + CancellationToken cancellationToken = default) + { + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var entity = await GetDbSet(context) + .FirstOrDefaultAsync(e => e.Id == id, cancellationToken); + + if (entity == null) + { + _logger.LogWarning("Disable rejected: {EntityName} {Id} not found", EntityName, id); + return ValidationResult.Failure($"{EntityName} not found"); + } + + if (entity.IsPrimary) + { + _logger.LogWarning("Disable rejected: {EntityName} {Id} is primary", EntityName, id); + return ValidationResult.Failure( + $"Cannot disable a primary {EntityName}. Set another {EntityName} as primary first."); + } + + return ValidationResult.Success(); + } + + /// + /// Validates that at least one credential in the group is enabled. + /// + protected async Task ValidateHasEnabledAsync( + Expression> groupAndEnabledPredicate, + CancellationToken cancellationToken = default) + { + using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var hasEnabled = await GetDbSet(context) + .AnyAsync(groupAndEnabledPredicate, cancellationToken); + + if (!hasEnabled) + { + _logger.LogWarning( + "Validation failed: {GroupName} has no enabled {EntityName}s", + GroupName, EntityName); + return ValidationResult.Failure( + $"{GroupName} must have at least one enabled {EntityName}"); + } + + return ValidationResult.Success(); + } +} diff --git a/Shared/ConduitLLM.Configuration/Services/FunctionCallAuditService.cs b/Shared/ConduitLLM.Configuration/Services/FunctionCallAuditService.cs index 7d1f933ce..9f8ef2564 100644 --- a/Shared/ConduitLLM.Configuration/Services/FunctionCallAuditService.cs +++ b/Shared/ConduitLLM.Configuration/Services/FunctionCallAuditService.cs @@ -1,11 +1,7 @@ -using System.Collections.Concurrent; -using ConduitLLM.Configuration; using ConduitLLM.Functions.Entities; using ConduitLLM.Functions.Enums; using ConduitLLM.Functions.Interfaces; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace ConduitLLM.Configuration.Services; @@ -14,114 +10,76 @@ namespace ConduitLLM.Configuration.Services; /// Service for logging function call audit events with batch processing. /// Implements IHostedService for background batch flushing. /// -public class FunctionCallAuditService : IFunctionCallAuditService, IHostedService, IDisposable +public class FunctionCallAuditService : BatchAuditServiceBase, IFunctionCallAuditService { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly ConcurrentQueue _eventQueue = new(); - private readonly SemaphoreSlim _flushSemaphore = new(1, 1); - private readonly Timer _flushTimer; - - private const int BatchSize = 100; - private const int FlushIntervalSeconds = 10; - private const int DataRetentionDays = 90; - + /// + /// Creates a new instance of the FunctionCallAuditService. + /// + /// Service provider for creating scoped DbContexts + /// Logger instance public FunctionCallAuditService( IServiceProvider serviceProvider, ILogger logger) + : base(serviceProvider, logger) { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - _flushTimer = new Timer( - callback: async _ => await FlushEventsInternalAsync(), - state: null, - dueTime: TimeSpan.FromSeconds(FlushIntervalSeconds), - period: TimeSpan.FromSeconds(FlushIntervalSeconds)); } - public void LogFunctionCallEvent(FunctionCallAudit auditEvent) - { - if (auditEvent == null) - { - _logger.LogWarning("Attempted to log null function call audit event"); - return; - } + #region Template Method Implementations - _eventQueue.Enqueue(auditEvent); + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.FunctionCallAudits; - // Auto-flush if batch size reached (fire-and-forget) - if (_eventQueue.Count >= BatchSize) - { - _ = Task.Run(async () => await FlushEventsInternalAsync()); - } - } + /// + protected override string EntityName => "FunctionCall"; + + #endregion - public async Task LogFunctionCallEventAsync(FunctionCallAudit auditEvent) + #region Configuration Overrides + + /// + /// Uses bulk delete (ExecuteDeleteAsync) for more efficient cleanup. + /// + protected override bool UseBulkDelete => true; + + /// + /// Overrides the default data retention scheduling to run only once on startup. + /// + /// Cancellation token + protected override async Task ScheduleDataRetentionAsync(CancellationToken cancellationToken) { - if (auditEvent == null) + // Run cleanup once on startup (no periodic cleanup) + try { - _logger.LogWarning("Attempted to log null function call audit event"); - return; + await CleanupOldEventsAsync(); } - - _eventQueue.Enqueue(auditEvent); - - // Auto-flush if batch size reached (wait for completion) - if (_eventQueue.Count >= BatchSize) + catch (Exception ex) { - await FlushEventsInternalAsync(wait: true); + Logger.LogError(ex, "Error during startup {EntityName} audit event cleanup", EntityName); } } - public async Task FlushEventsAsync() - { - await FlushEventsInternalAsync(wait: true); - } - - private async Task FlushEventsInternalAsync(bool wait = false) - { - var timeout = wait ? Timeout.InfiniteTimeSpan : TimeSpan.Zero; - - if (!await _flushSemaphore.WaitAsync(timeout)) - { - // Another flush is in progress - return; - } + #endregion - try - { - var events = new List(); + #region IFunctionCallAuditService Implementation (Wrapper Methods) - // Dequeue up to BatchSize events - while (events.Count < BatchSize && _eventQueue.TryDequeue(out var auditEvent)) - { - events.Add(auditEvent); - } + /// + public void LogFunctionCallEvent(FunctionCallAudit auditEvent) + => LogEvent(auditEvent); - if (events.Count == 0) - { - return; - } + /// + public Task LogFunctionCallEventAsync(FunctionCallAudit auditEvent) + => LogEventAsync(auditEvent); - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); + /// + public new Task FlushEventsAsync() + => base.FlushEventsAsync(); - await dbContext.FunctionCallAudits.AddRangeAsync(events); - await dbContext.SaveChangesAsync(); + #endregion - _logger.LogDebug("Flushed {Count} function call audit events to database", events.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error flushing function call audit events to database"); - } - finally - { - _flushSemaphore.Release(); - } - } + #region Domain-Specific Query Methods + /// public async Task<(List Events, int TotalCount)> GetAuditEventsAsync( DateTime from, DateTime to, @@ -132,134 +90,59 @@ private async Task FlushEventsInternalAsync(bool wait = false) int pageNumber = 1, int pageSize = 100) { - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var query = dbContext.FunctionCallAudits - .AsNoTracking() - .Where(e => e.Timestamp >= from && e.Timestamp <= to); - - if (eventType.HasValue) - { - query = query.Where(e => e.EventType == eventType.Value); - } - - if (virtualKeyId.HasValue) - { - query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); - } - - if (functionConfigurationId.HasValue) - { - query = query.Where(e => e.FunctionConfigurationId == functionConfigurationId.Value); - } - - if (chatCompletionId.HasValue) - { - query = query.Where(e => e.ChatCompletionId == chatCompletionId.Value); - } - - var totalCount = await query.CountAsync(); - - var events = await query - .OrderByDescending(e => e.Timestamp) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); - - return (events, totalCount); + return await GetPagedEventsAsync(from, to, pageNumber, pageSize, query => + { + if (eventType.HasValue) + query = query.Where(e => e.EventType == eventType.Value); + if (virtualKeyId.HasValue) + query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); + if (functionConfigurationId.HasValue) + query = query.Where(e => e.FunctionConfigurationId == functionConfigurationId.Value); + if (chatCompletionId.HasValue) + query = query.Where(e => e.ChatCompletionId == chatCompletionId.Value); + return query; + }); } + /// public async Task GetAuditSummaryAsync( DateTime from, DateTime to, int? virtualKeyId = null) { - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var query = dbContext.FunctionCallAudits - .AsNoTracking() - .Where(e => e.Timestamp >= from && e.Timestamp <= to); - - if (virtualKeyId.HasValue) - { - query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); - } - - var events = await query.ToListAsync(); - - var summary = new FunctionCallAuditSummary - { - TotalFunctionCalls = events.Count, - SuccessfulCalls = events.Count(e => e.EventType == FunctionCallAuditEventType.FunctionCallExecutionCompleted), - FailedCalls = events.Count(e => e.EventType == FunctionCallAuditEventType.FunctionCallExecutionFailed), - TotalCost = events.Where(e => e.Cost.HasValue).Sum(e => e.Cost!.Value), - CallsByEventType = events.GroupBy(e => e.EventType) - .ToDictionary(g => g.Key, g => g.Count()) - }; - - // Get calls by function configuration - var functionConfigs = await dbContext.FunctionConfigurations - .Where(fc => events.Select(e => e.FunctionConfigurationId).Contains(fc.Id)) - .ToDictionaryAsync(fc => fc.Id, fc => fc.ConfigurationName); - - summary.CallsByFunction = events - .GroupBy(e => e.FunctionConfigurationId) - .ToDictionary( - g => functionConfigs.TryGetValue(g.Key, out var name) ? name : $"Unknown ({g.Key})", - g => g.Count()); - - return summary; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("FunctionCallAuditService started. Batch size: {BatchSize}, Flush interval: {Interval}s", - BatchSize, FlushIntervalSeconds); - - // Clean up old audit records on startup - await CleanupOldRecordsAsync(); - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("FunctionCallAuditService stopping. Flushing remaining events..."); - - // Flush any remaining events - await FlushEventsInternalAsync(wait: true); - - _logger.LogInformation("FunctionCallAuditService stopped"); - } - - private async Task CleanupOldRecordsAsync() - { - try + return await ExecuteQueryAsync(async context => { - using var scope = _serviceProvider.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - var cutoffDate = DateTime.UtcNow.AddDays(-DataRetentionDays); - - var deletedCount = await dbContext.FunctionCallAudits - .Where(e => e.Timestamp < cutoffDate) - .ExecuteDeleteAsync(); + var events = await GetFilteredEventsAsync(from, to, query => + { + if (virtualKeyId.HasValue) + query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); + return query; + }); - if (deletedCount > 0) + var summary = new FunctionCallAuditSummary { - _logger.LogInformation("Cleaned up {Count} function call audit records older than {Days} days", - deletedCount, DataRetentionDays); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error cleaning up old function call audit records"); - } + TotalFunctionCalls = events.Count, + SuccessfulCalls = events.Count(e => e.EventType == FunctionCallAuditEventType.FunctionCallExecutionCompleted), + FailedCalls = events.Count(e => e.EventType == FunctionCallAuditEventType.FunctionCallExecutionFailed), + TotalCost = events.Where(e => e.Cost.HasValue).Sum(e => e.Cost!.Value), + CallsByEventType = events.GroupBy(e => e.EventType) + .ToDictionary(g => g.Key, g => g.Count()) + }; + + // Get calls by function configuration + var functionConfigs = await context.FunctionConfigurations + .Where(fc => events.Select(e => e.FunctionConfigurationId).Contains(fc.Id)) + .ToDictionaryAsync(fc => fc.Id, fc => fc.ConfigurationName); + + summary.CallsByFunction = events + .GroupBy(e => e.FunctionConfigurationId) + .ToDictionary( + g => functionConfigs.TryGetValue(g.Key, out var name) ? name : $"Unknown ({g.Key})", + g => g.Count()); + + return summary; + }); } - public void Dispose() - { - _flushTimer?.Dispose(); - _flushSemaphore?.Dispose(); - } + #endregion } diff --git a/Shared/ConduitLLM.Configuration/Services/FunctionCredentialValidator.cs b/Shared/ConduitLLM.Configuration/Services/FunctionCredentialValidator.cs index c2ae8d1eb..2bfd96cbf 100644 --- a/Shared/ConduitLLM.Configuration/Services/FunctionCredentialValidator.cs +++ b/Shared/ConduitLLM.Configuration/Services/FunctionCredentialValidator.cs @@ -1,116 +1,40 @@ +using ConduitLLM.Functions.Entities; using ConduitLLM.Functions.Enums; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace ConduitLLM.Configuration.Services; /// /// Validates business rules for FunctionCredential operations -/// Mirrors the validation patterns used in ProviderKeyCredentialValidator /// -public class FunctionCredentialValidator +public class FunctionCredentialValidator : CredentialValidatorBase { - private readonly IDbContextFactory _dbContextFactory; - private const int MaxCredentialsPerProviderType = 32; + protected override int MaxPerGroup => 32; + protected override string EntityName => "credential"; + protected override string GroupName => "Provider type"; + protected override DbSet GetDbSet(ConduitDbContext context) => context.FunctionCredentials; - public FunctionCredentialValidator(IDbContextFactory dbContextFactory) - { - _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); - } + public FunctionCredentialValidator( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { } /// /// Validates if a new credential can be added to a provider type /// - public async Task ValidateAddCredentialAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var currentCredentialCount = await dbContext.FunctionCredentials - .CountAsync(c => c.ProviderType == providerType, cancellationToken); - - if (currentCredentialCount >= MaxCredentialsPerProviderType) - { - return CredentialValidationResult.Failure($"Provider type already has the maximum of {MaxCredentialsPerProviderType} credentials"); - } - - return CredentialValidationResult.Success(); - } - - /// - /// Validates if a credential can be set as primary - /// - public async Task ValidateSetPrimaryAsync(int credentialId, CancellationToken cancellationToken = default) - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var credential = await dbContext.FunctionCredentials - .FirstOrDefaultAsync(c => c.Id == credentialId, cancellationToken); - - if (credential == null) - { - return CredentialValidationResult.Failure("Credential not found"); - } - - if (!credential.IsEnabled) - { - return CredentialValidationResult.Failure("Cannot set a disabled credential as primary"); - } - - return CredentialValidationResult.Success(); - } + public Task ValidateAddCredentialAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) + => ValidateAddAsync(c => c.ProviderType == providerType, cancellationToken); /// /// Validates if a credential can be disabled /// - public async Task ValidateDisableCredentialAsync(int credentialId, CancellationToken cancellationToken = default) - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var credential = await dbContext.FunctionCredentials - .FirstOrDefaultAsync(c => c.Id == credentialId, cancellationToken); - - if (credential == null) - { - return CredentialValidationResult.Failure("Credential not found"); - } - - if (credential.IsPrimary) - { - return CredentialValidationResult.Failure("Cannot disable a primary credential. Set another credential as primary first."); - } - - return CredentialValidationResult.Success(); - } + public Task ValidateDisableCredentialAsync(int credentialId, CancellationToken cancellationToken = default) + => ValidateDisableAsync(credentialId, cancellationToken); /// /// Ensures at least one credential is enabled for a provider type /// - public async Task ValidateProviderTypeHasEnabledCredentialAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) - { - using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - - var hasEnabledCredential = await dbContext.FunctionCredentials - .AnyAsync(c => c.ProviderType == providerType && c.IsEnabled, cancellationToken); - - if (!hasEnabledCredential) - { - return CredentialValidationResult.Failure("Provider type must have at least one enabled credential"); - } - - return CredentialValidationResult.Success(); - } -} - -public class CredentialValidationResult -{ - public bool IsValid { get; private set; } - public string? ErrorMessage { get; private set; } - - private CredentialValidationResult(bool isValid, string? errorMessage = null) - { - IsValid = isValid; - ErrorMessage = errorMessage; - } - - public static CredentialValidationResult Success() => new CredentialValidationResult(true); - public static CredentialValidationResult Failure(string errorMessage) => new CredentialValidationResult(false, errorMessage); + public Task ValidateProviderTypeHasEnabledCredentialAsync(FunctionProviderType providerType, CancellationToken cancellationToken = default) + => ValidateHasEnabledAsync(c => c.ProviderType == providerType && c.IsEnabled, cancellationToken); } diff --git a/Shared/ConduitLLM.Configuration/Services/GlobalSettingsCacheService.cs b/Shared/ConduitLLM.Configuration/Services/GlobalSettingsCacheService.cs index 04e74d941..67218c1c3 100644 --- a/Shared/ConduitLLM.Configuration/Services/GlobalSettingsCacheService.cs +++ b/Shared/ConduitLLM.Configuration/Services/GlobalSettingsCacheService.cs @@ -81,135 +81,121 @@ public Task StopAsync(CancellationToken cancellationToken) } public async Task GetMaxAgenticIterationsAsync() + => await GetClampedIntSettingAsync(KEY_MAX_AGENTIC_ITERATIONS, DEFAULT_MAX_AGENTIC_ITERATIONS, + MIN_VALID_ITERATIONS, MAX_VALID_ITERATIONS, "Max agentic iterations"); + + public async Task GetMinAgenticIterationsAsync() + => await GetClampedIntSettingAsync(KEY_MIN_AGENTIC_ITERATIONS, DEFAULT_MIN_AGENTIC_ITERATIONS, + MIN_VALID_ITERATIONS, MAX_VALID_ITERATIONS, "Min agentic iterations"); + + public async Task GetDefaultAgenticModeEnabledAsync() + => await GetBoolSettingAsync(KEY_DEFAULT_AGENTIC_ENABLED, DEFAULT_AGENTIC_ENABLED, "Default agentic enabled"); + + public async Task GetLLMCachingEnabledAsync() { - var value = await GetSettingAsync(KEY_MAX_AGENTIC_ITERATIONS); + var value = await GetSettingAsync(KEY_LLM_CACHING_ENABLED); if (string.IsNullOrWhiteSpace(value)) { - _logger.LogDebug("Max agentic iterations setting not found, using default: {Default}", DEFAULT_MAX_AGENTIC_ITERATIONS); - return DEFAULT_MAX_AGENTIC_ITERATIONS; + _logger.LogDebug("LLM caching enabled setting not found, using default: {Default}", DEFAULT_LLM_CACHING_ENABLED); + return DEFAULT_LLM_CACHING_ENABLED; } - if (!int.TryParse(value, out var maxIterations)) + // The value is stored as JSON metadata, try to parse it + try + { + var metadata = System.Text.Json.JsonSerializer.Deserialize(value); + if (metadata != null) + { + return metadata.Enabled; + } + } + catch (System.Text.Json.JsonException) { - _logger.LogWarning("Failed to parse max agentic iterations value '{Value}', using default: {Default}", - value, DEFAULT_MAX_AGENTIC_ITERATIONS); - return DEFAULT_MAX_AGENTIC_ITERATIONS; + // Fall back to direct boolean parsing if not JSON } - // Clamp to valid range - var clamped = Math.Clamp(maxIterations, MIN_VALID_ITERATIONS, MAX_VALID_ITERATIONS); - if (clamped != maxIterations) + if (TryParseFuzzyBool(value, out var result)) { - _logger.LogWarning("Max agentic iterations {Value} out of valid range ({Min}-{Max}), clamping to {Clamped}", - maxIterations, MIN_VALID_ITERATIONS, MAX_VALID_ITERATIONS, clamped); + return result; } - return clamped; + _logger.LogWarning("Failed to parse LLM caching enabled value '{Value}', using default: {Default}", + value, DEFAULT_LLM_CACHING_ENABLED); + return DEFAULT_LLM_CACHING_ENABLED; } - public async Task GetMinAgenticIterationsAsync() + /// + public Task GetSettingValueAsync(string key) { - var value = await GetSettingAsync(KEY_MIN_AGENTIC_ITERATIONS); - - if (string.IsNullOrWhiteSpace(value)) - { - _logger.LogDebug("Min agentic iterations setting not found, using default: {Default}", DEFAULT_MIN_AGENTIC_ITERATIONS); - return DEFAULT_MIN_AGENTIC_ITERATIONS; - } + return GetSettingAsync(key); + } - if (!int.TryParse(value, out var minIterations)) - { - _logger.LogWarning("Failed to parse min agentic iterations value '{Value}', using default: {Default}", - value, DEFAULT_MIN_AGENTIC_ITERATIONS); - return DEFAULT_MIN_AGENTIC_ITERATIONS; - } + /// + /// Parses a string value as boolean, accepting "true"/"false", "1"/"0", "yes"/"no", "on"/"off". + /// + private static bool TryParseFuzzyBool(string value, out bool result) + { + if (bool.TryParse(value, out result)) + return true; - // Clamp to valid range - var clamped = Math.Clamp(minIterations, MIN_VALID_ITERATIONS, MAX_VALID_ITERATIONS); - if (clamped != minIterations) - { - _logger.LogWarning("Min agentic iterations {Value} out of valid range ({Min}-{Max}), clamping to {Clamped}", - minIterations, MIN_VALID_ITERATIONS, MAX_VALID_ITERATIONS, clamped); - } + var normalized = value.Trim().ToLowerInvariant(); + if (normalized is "1" or "yes" or "on") { result = true; return true; } + if (normalized is "0" or "no" or "off") { result = false; return true; } - return clamped; + result = default; + return false; } - public async Task GetDefaultAgenticModeEnabledAsync() + /// + /// Gets a boolean setting with fuzzy parsing and fallback to default. + /// + private async Task GetBoolSettingAsync(string key, bool defaultValue, string settingName) { - var value = await GetSettingAsync(KEY_DEFAULT_AGENTIC_ENABLED); + var value = await GetSettingAsync(key); if (string.IsNullOrWhiteSpace(value)) { - _logger.LogDebug("Default agentic enabled setting not found, using default: {Default}", DEFAULT_AGENTIC_ENABLED); - return DEFAULT_AGENTIC_ENABLED; - } - - if (bool.TryParse(value, out var enabled)) - { - return enabled; + _logger.LogDebug("{SettingName} setting not found, using default: {Default}", settingName, defaultValue); + return defaultValue; } - // Try parsing common string representations - var normalized = value.Trim().ToLowerInvariant(); - if (normalized == "1" || normalized == "yes" || normalized == "on") - { - return true; - } - if (normalized == "0" || normalized == "no" || normalized == "off") - { - return false; - } + if (TryParseFuzzyBool(value, out var result)) + return result; - _logger.LogWarning("Failed to parse default agentic enabled value '{Value}', using default: {Default}", - value, DEFAULT_AGENTIC_ENABLED); - return DEFAULT_AGENTIC_ENABLED; + _logger.LogWarning("Failed to parse {SettingName} value '{Value}', using default: {Default}", + settingName, value, defaultValue); + return defaultValue; } - public async Task GetLLMCachingEnabledAsync() + /// + /// Gets an integer setting, clamped to the specified range, with fallback to default. + /// + private async Task GetClampedIntSettingAsync(string key, int defaultValue, int min, int max, string settingName) { - var value = await GetSettingAsync(KEY_LLM_CACHING_ENABLED); + var value = await GetSettingAsync(key); if (string.IsNullOrWhiteSpace(value)) { - _logger.LogDebug("LLM caching enabled setting not found, using default: {Default}", DEFAULT_LLM_CACHING_ENABLED); - return DEFAULT_LLM_CACHING_ENABLED; - } - - // The value is stored as JSON metadata, try to parse it - try - { - var metadata = System.Text.Json.JsonSerializer.Deserialize(value); - if (metadata != null) - { - return metadata.Enabled; - } - } - catch (System.Text.Json.JsonException) - { - // Fall back to direct boolean parsing if not JSON + _logger.LogDebug("{SettingName} setting not found, using default: {Default}", settingName, defaultValue); + return defaultValue; } - if (bool.TryParse(value, out var enabled)) + if (!int.TryParse(value, out var parsed)) { - return enabled; + _logger.LogWarning("Failed to parse {SettingName} value '{Value}', using default: {Default}", + settingName, value, defaultValue); + return defaultValue; } - // Try parsing common string representations - var normalized = value.Trim().ToLowerInvariant(); - if (normalized == "1" || normalized == "yes" || normalized == "on") - { - return true; - } - if (normalized == "0" || normalized == "no" || normalized == "off") + var clamped = Math.Clamp(parsed, min, max); + if (clamped != parsed) { - return false; + _logger.LogWarning("{SettingName} {Value} out of valid range ({Min}-{Max}), clamping to {Clamped}", + settingName, parsed, min, max, clamped); } - _logger.LogWarning("Failed to parse LLM caching enabled value '{Value}', using default: {Default}", - value, DEFAULT_LLM_CACHING_ENABLED); - return DEFAULT_LLM_CACHING_ENABLED; + return clamped; } public async Task InvalidateSettingAsync(string settingKey) @@ -319,7 +305,7 @@ private async Task LoadAllSettingsAsync(CancellationToken cancellationToken) using (var scope = _scopeFactory.CreateScope()) { var repository = scope.ServiceProvider.GetRequiredService(); - var settings = await repository.GetAllAsync(); + var settings = await repository.GetAllUnboundedAsync(); foreach (var setting in settings) { diff --git a/Shared/ConduitLLM.Configuration/Services/ModelCostService.cs b/Shared/ConduitLLM.Configuration/Services/ModelCostService.cs index 56f8f4e97..804e3db9d 100644 --- a/Shared/ConduitLLM.Configuration/Services/ModelCostService.cs +++ b/Shared/ConduitLLM.Configuration/Services/ModelCostService.cs @@ -1,54 +1,34 @@ -using System.Text.Json; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; namespace ConduitLLM.Configuration.Services; /// -/// Service for managing and retrieving model costs, with hybrid caching support (L1: Memory, L2: Redis) +/// Service for managing and retrieving model costs. Pure repository operations โ€” caching is handled by the CachedModelCostService decorator. /// public class ModelCostService : IModelCostService { private readonly IModelCostRepository _modelCostRepository; private readonly IModelProviderMappingRepository _modelProviderMappingRepository; - private readonly IMemoryCache _memoryCache; - private readonly IDistributedCache? _distributedCache; private readonly ILogger _logger; - private readonly TimeSpan _memoryCacheDuration = TimeSpan.FromMinutes(15); - private readonly TimeSpan _distributedCacheDuration = TimeSpan.FromHours(1); - private readonly JsonSerializerOptions _jsonOptions; - private const string CacheKeyPrefix = "ModelCost_"; - private const string AllModelsCacheKey = CacheKeyPrefix + "All"; /// /// Creates a new instance of the ModelCostService /// /// The model cost repository /// The model provider mapping repository - /// The memory cache /// The logger - /// The distributed cache (optional) public ModelCostService( IModelCostRepository modelCostRepository, IModelProviderMappingRepository modelProviderMappingRepository, - IMemoryCache memoryCache, - ILogger logger, - IDistributedCache? distributedCache = null) + ILogger logger) { _modelCostRepository = modelCostRepository ?? throw new ArgumentNullException(nameof(modelCostRepository)); _modelProviderMappingRepository = modelProviderMappingRepository ?? throw new ArgumentNullException(nameof(modelProviderMappingRepository)); - _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _distributedCache = distributedCache; - _jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; } /// @@ -61,27 +41,16 @@ public ModelCostService( try { - string cacheKey = $"{CacheKeyPrefix}{modelId}"; - - // Try hybrid cache first - var cachedCost = await GetFromHybridCacheAsync(cacheKey); - if (cachedCost != null) - { - _logger.LogDebug("Cache hit for model cost: {ModelId}", modelId); - return cachedCost; - } - - _logger.LogDebug("Cache miss for model cost: {ModelId}, querying database", modelId); - // Get all model costs with their associated ModelProviderTypeAssociations - var allCosts = await _modelCostRepository.GetAllAsync(cancellationToken); - + var allCosts = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelCostRepository.GetPaginatedAsync, cancellationToken: cancellationToken); + // Find a cost where one of its associated ModelProviderTypeAssociations has this identifier var now = DateTime.UtcNow; var modelCost = allCosts .Where(cost => cost.IsActive && cost.EffectiveDate <= now) .Where(cost => !cost.ExpiryDate.HasValue || cost.ExpiryDate.Value > now) - .Where(cost => cost.ModelProviderTypeAssociations.Any(assoc => + .Where(cost => cost.ModelProviderTypeAssociations.Any(assoc => assoc.Identifier == modelId && assoc.IsEnabled)) .OrderByDescending(cost => cost.Priority) .ThenByDescending(cost => cost.EffectiveDate) @@ -92,7 +61,6 @@ public ModelCostService( _logger.LogDebug("No model cost found for identifier: {ModelId}", modelId); } - await SetInHybridCacheAsync(cacheKey, modelCost); return modelCost; } catch (Exception ex) @@ -107,18 +75,6 @@ public ModelCostService( { try { - string cacheKey = $"{CacheKeyPrefix}Id_{modelCostId}"; - - // Try hybrid cache first - var cachedCost = await GetFromHybridCacheAsync(cacheKey); - if (cachedCost != null) - { - _logger.LogDebug("Cache hit for model cost ID: {ModelCostId}", modelCostId); - return cachedCost; - } - - _logger.LogDebug("Cache miss for model cost ID: {ModelCostId}, querying database", modelCostId); - var modelCost = await _modelCostRepository.GetByIdAsync(modelCostId, cancellationToken); if (modelCost == null) @@ -136,7 +92,6 @@ public ModelCostService( return null; } - await SetInHybridCacheAsync(cacheKey, modelCost); return modelCost; } catch (Exception ex) @@ -151,17 +106,8 @@ public async Task> ListModelCostsAsync(CancellationToken cancell { try { - // Try hybrid cache first - var cachedCosts = await GetFromHybridCacheAsync?>(AllModelsCacheKey); - if (cachedCosts != null) - { - return cachedCosts; - } - - var costs = await _modelCostRepository.GetAllAsync(cancellationToken); - - await SetInHybridCacheAsync(AllModelsCacheKey, costs); - return costs; + return await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelCostRepository.GetPaginatedAsync, cancellationToken: cancellationToken); } catch (Exception ex) { @@ -184,9 +130,8 @@ public async Task AddModelCostAsync(ModelCost modelCost, CancellationToken cance modelCost.UpdatedAt = DateTime.UtcNow; await _modelCostRepository.CreateAsync(modelCost, cancellationToken); - - // Clear cache - await ClearCacheAsync(); + _logger.LogInformation("Created model cost {CostName} (ID: {CostId}) with input={InputCost}/M, output={OutputCost}/M", + modelCost.CostName, modelCost.Id, modelCost.InputCostPerMillionTokens, modelCost.OutputCostPerMillionTokens); } catch (Exception ex) { @@ -209,6 +154,7 @@ public async Task UpdateModelCostAsync(ModelCost modelCost, CancellationTo if (existingCost == null) { + _logger.LogWarning("Attempted to update non-existent model cost {ModelCostId}", modelCost.Id); return false; } @@ -221,10 +167,11 @@ public async Task UpdateModelCostAsync(ModelCost modelCost, CancellationTo existingCost.CachedInputWriteCostPerMillionTokens = modelCost.CachedInputWriteCostPerMillionTokens; existingCost.UpdatedAt = DateTime.UtcNow; - bool result = await _modelCostRepository.UpdateAsync(existingCost, cancellationToken); - - // Clear cache - await ClearCacheAsync(); + var result = await _modelCostRepository.UpdateAsync(existingCost, cancellationToken); + if (result) + { + _logger.LogInformation("Updated model cost {CostName} (ID: {ModelCostId})", existingCost.CostName, modelCost.Id); + } return result; } catch (Exception ex) @@ -239,14 +186,15 @@ public async Task DeleteModelCostAsync(int id, CancellationToken cancellat { try { - bool result = await _modelCostRepository.DeleteAsync(id, cancellationToken); - + var result = await _modelCostRepository.DeleteAsync(id, cancellationToken); if (result) { - // Clear cache - await ClearCacheAsync(); + _logger.LogInformation("Deleted model cost {ModelCostId}", id); + } + else + { + _logger.LogWarning("Attempted to delete non-existent model cost {ModelCostId}", id); } - return result; } catch (Exception ex) @@ -256,112 +204,10 @@ public async Task DeleteModelCostAsync(int id, CancellationToken cancellat } } - /// - /// Gets a value from hybrid cache (L1: Memory, L2: Redis) - /// - private async Task GetFromHybridCacheAsync(string key) - { - // L1 Cache (Memory) - Fast access - if (_memoryCache.TryGetValue(key, out T? memoryValue)) - { - _logger.LogDebug("Memory cache hit for key: {Key}", key); - return memoryValue; - } - - // L2 Cache (Redis) - Shared state - if (_distributedCache != null) - { - try - { - var cachedData = await _distributedCache.GetStringAsync(key); - if (!string.IsNullOrEmpty(cachedData)) - { - var distributedValue = JsonSerializer.Deserialize(cachedData, _jsonOptions); - if (distributedValue != null) - { - // Populate L1 cache with shorter TTL - _memoryCache.Set(key, distributedValue, _memoryCacheDuration); - _logger.LogDebug("Distributed cache hit for key: {Key}", key); - return distributedValue; - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error retrieving from distributed cache for key: {Key}", key); - } - } - - return default(T); - } - - /// - /// Sets a value in hybrid cache (L1: Memory, L2: Redis) - /// - private async Task SetInHybridCacheAsync(string key, T value) - { - try - { - // Set in distributed cache first - if (_distributedCache != null) - { - var json = JsonSerializer.Serialize(value, _jsonOptions); - await _distributedCache.SetStringAsync(key, json, new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = _distributedCacheDuration - }); - } - - // Set in memory cache with shorter TTL for consistency - _memoryCache.Set(key, value, _memoryCacheDuration); - - _logger.LogDebug("Set value in hybrid cache for key: {Key}", key); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error setting value in hybrid cache for key: {Key}", key); - // Still cache in memory as fallback - _memoryCache.Set(key, value, _memoryCacheDuration); - } - } - /// - public void ClearCache() + public Task ClearCacheAsync(CancellationToken cancellationToken = default) { - // Synchronous wrapper for async cache clearing - Task.Run(async () => await ClearCacheAsync()).Wait(); - } - - /// - /// Asynchronous version of cache clearing with proper hybrid cache support - /// - public async Task ClearCacheAsync() - { - // Remove all ModelCost-related entries from the cache - _logger.LogInformation("Clearing model cost cache"); - - // Clear memory cache entries by compacting - if (_memoryCache is MemoryCache mc) - { - mc.Compact(1.0); - } - - // For distributed cache, we'd need to scan for keys with our prefix - // This is a simplified implementation - Redis keys will expire naturally - if (_distributedCache != null) - { - try - { - // Remove the known cache key - await _distributedCache.RemoveAsync(AllModelsCacheKey); - _logger.LogInformation("Distributed cache entries cleared (known keys only)"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error clearing distributed cache"); - } - } - - _logger.LogInformation("Model cost cache cleared"); + // No-op: caching is handled by the CachedModelCostService decorator + return Task.CompletedTask; } } diff --git a/Shared/ConduitLLM.Configuration/Services/NotificationService.cs b/Shared/ConduitLLM.Configuration/Services/NotificationService.cs index ff5a35485..2d08eec41 100644 --- a/Shared/ConduitLLM.Configuration/Services/NotificationService.cs +++ b/Shared/ConduitLLM.Configuration/Services/NotificationService.cs @@ -1,4 +1,5 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; using Microsoft.Extensions.Logging; @@ -63,11 +64,15 @@ public async Task CheckKeyExpirationAsync() var now = DateTime.UtcNow; var warningDate = now.AddDays(ExpirationWarningDays); - var keys = (await _virtualKeyRepository.GetAllAsync()) + var allKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _virtualKeyRepository.GetPaginatedAsync); + var keys = allKeys .Where(k => k.IsEnabled && k.ExpiresAt.HasValue) .Where(k => k.ExpiresAt.HasValue && k.ExpiresAt <= warningDate) .ToList(); + _logger.LogInformation("Checking key expiration: found {Count} keys expiring within {Days} days", keys.Count, ExpirationWarningDays); + foreach (var key in keys) { // ExpiresAt is guaranteed to have a value based on the query above @@ -107,7 +112,8 @@ private async Task CreateBudgetNotificationAsync(VirtualKey key, decimal percent try { // Get existing notifications for this key - var notifications = await _notificationRepository.GetAllAsync(); + var notifications = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _notificationRepository.GetPaginatedAsync); var existingNotification = notifications .Where(n => n.VirtualKeyId == key.Id) .Where(n => n.Type == NotificationType.BudgetWarning) @@ -156,7 +162,8 @@ private async Task CreateExpirationNotificationAsync(VirtualKey key, double days try { // Get existing notifications for this key - var notifications = await _notificationRepository.GetAllAsync(); + var notifications = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _notificationRepository.GetPaginatedAsync); var existingNotification = notifications .Where(n => n.VirtualKeyId == key.Id) .Where(n => n.Type == NotificationType.ExpirationWarning) @@ -185,6 +192,7 @@ private async Task CreateExpirationNotificationAsync(VirtualKey key, double days existingNotification.CreatedAt = DateTime.UtcNow; await _notificationRepository.UpdateAsync(existingNotification); + _logger.LogDebug("Updated expiration notification for key {KeyId}: {Severity}", key.Id, severity); } else { @@ -200,6 +208,8 @@ private async Task CreateExpirationNotificationAsync(VirtualKey key, double days }; await _notificationRepository.CreateAsync(notification); + _logger.LogInformation("Created expiration notification for key {KeyId}: {Severity}, {DaysLeft:F1} days remaining", + key.Id, severity, daysLeft); } } catch (Exception ex) @@ -218,6 +228,7 @@ public async Task MarkAsReadAsync(int id) try { await _notificationRepository.MarkAsReadAsync(id); + _logger.LogDebug("Marked notification {NotificationId} as read", id); } catch (Exception ex) { @@ -234,7 +245,9 @@ public async Task MarkAllAsReadForKeyAsync(int virtualKeyId) { try { - var notifications = (await _notificationRepository.GetAllAsync()) + var allNotifications = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _notificationRepository.GetPaginatedAsync); + var notifications = allNotifications .Where(n => n.VirtualKeyId == virtualKeyId && !n.IsRead) .ToList(); @@ -243,6 +256,11 @@ public async Task MarkAllAsReadForKeyAsync(int virtualKeyId) notification.IsRead = true; await _notificationRepository.UpdateAsync(notification); } + + if (notifications.Count > 0) + { + _logger.LogDebug("Marked {Count} notifications as read for key {KeyId}", notifications.Count, virtualKeyId); + } } catch (Exception ex) { @@ -259,7 +277,8 @@ public async Task> GetUnreadNotificationsAsync(int virtualKey { try { - var notifications = await _notificationRepository.GetAllAsync(); + var notifications = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _notificationRepository.GetPaginatedAsync); return notifications .Where(n => n.VirtualKeyId == virtualKeyId && !n.IsRead) .OrderByDescending(n => n.CreatedAt) diff --git a/Shared/ConduitLLM.Configuration/Services/PricingAuditService.cs b/Shared/ConduitLLM.Configuration/Services/PricingAuditService.cs index 07e20d51d..622deb7bd 100644 --- a/Shared/ConduitLLM.Configuration/Services/PricingAuditService.cs +++ b/Shared/ConduitLLM.Configuration/Services/PricingAuditService.cs @@ -1,10 +1,7 @@ -using System.Collections.Concurrent; using System.Text.Json; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace ConduitLLM.Configuration.Services; @@ -12,57 +9,48 @@ namespace ConduitLLM.Configuration.Services; /// /// Service for auditing pricing rule evaluations with batch writing and async processing. /// -public class PricingAuditService : IPricingAuditService, IHostedService, IDisposable +public class PricingAuditService : BatchAuditServiceBase, IPricingAuditService { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly ConcurrentQueue _eventQueue; - private readonly Timer _flushTimer; - private readonly SemaphoreSlim _flushSemaphore; - private bool _disposed; - - private const int BatchSize = 100; - private const int FlushIntervalSeconds = 10; - private const int RetentionDays = 90; - + /// + /// Creates a new instance of the PricingAuditService. + /// + /// Service provider for creating scoped DbContexts + /// Logger instance public PricingAuditService( IServiceProvider serviceProvider, ILogger logger) + : base(serviceProvider, logger) { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _eventQueue = new ConcurrentQueue(); - _flushSemaphore = new SemaphoreSlim(1, 1); - _flushTimer = new Timer(FlushEvents, null, Timeout.Infinite, Timeout.Infinite); } + #region Template Method Implementations + /// - public async Task LogAsync(PricingAuditEvent auditEvent) - { - if (auditEvent == null) - throw new ArgumentNullException(nameof(auditEvent)); + protected override DbSet GetDbSet(ConduitDbContext context) + => context.PricingAuditEvents; - _eventQueue.Enqueue(auditEvent); + /// + protected override string EntityName => "Pricing"; - if (_eventQueue.Count >= BatchSize) - { - await FlushEventsAsync(wait: true); - } - } + #endregion + + #region IPricingAuditService Implementation (Wrapper Methods) + + /// + public Task LogAsync(PricingAuditEvent auditEvent) + => LogEventAsync(auditEvent); /// public void Log(PricingAuditEvent auditEvent) - { - if (auditEvent == null) - return; + => LogEvent(auditEvent); - _eventQueue.Enqueue(auditEvent); + /// + public Task CleanupOldAuditEventsAsync() + => CleanupOldEventsAsync(); - if (_eventQueue.Count >= BatchSize) - { - _ = Task.Run(async () => await FlushEventsAsync()); - } - } + #endregion + + #region Domain-Specific Query Methods /// public async Task<(List Events, int TotalCount)> GetAuditEventsAsync( @@ -74,31 +62,16 @@ public void Log(PricingAuditEvent auditEvent) int pageNumber = 1, int pageSize = 100) { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var query = context.PricingAuditEvents - .AsNoTracking() - .Where(e => e.Timestamp >= from && e.Timestamp <= to); - - if (virtualKeyId.HasValue) - query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); - - if (!string.IsNullOrEmpty(modelId)) - query = query.Where(e => e.ModelId == modelId); - - if (!string.IsNullOrEmpty(pricingType)) - query = query.Where(e => e.PricingType == pricingType); - - var totalCount = await query.CountAsync(); - - var events = await query - .OrderByDescending(e => e.Timestamp) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); - - return (events, totalCount); + return await GetPagedEventsAsync(from, to, pageNumber, pageSize, query => + { + if (virtualKeyId.HasValue) + query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); + if (!string.IsNullOrEmpty(modelId)) + query = query.Where(e => e.ModelId == modelId); + if (!string.IsNullOrEmpty(pricingType)) + query = query.Where(e => e.PricingType == pricingType); + return query; + }); } /// @@ -107,14 +80,12 @@ public async Task> GetByRequestIdAsync(string requestId) if (string.IsNullOrEmpty(requestId)) return new List(); - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - return await context.PricingAuditEvents - .AsNoTracking() - .Where(e => e.RequestId == requestId) - .OrderByDescending(e => e.Timestamp) - .ToListAsync(); + return await ExecuteQueryAsync(context => + context.PricingAuditEvents + .AsNoTracking() + .Where(e => e.RequestId == requestId) + .OrderByDescending(e => e.Timestamp) + .ToListAsync()); } /// @@ -123,17 +94,12 @@ public async Task GetSummaryAsync( DateTime to, int? virtualKeyId = null) { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var query = context.PricingAuditEvents - .AsNoTracking() - .Where(e => e.Timestamp >= from && e.Timestamp <= to); - - if (virtualKeyId.HasValue) - query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); - - var events = await query.ToListAsync(); + var events = await GetFilteredEventsAsync(from, to, query => + { + if (virtualKeyId.HasValue) + query = query.Where(e => e.VirtualKeyId == virtualKeyId.Value); + return query; + }); var summary = new PricingAuditSummary { @@ -172,189 +138,9 @@ public async Task GetSummaryAsync( return summary; } - /// - public async Task CleanupOldAuditEventsAsync() - { - const int DeleteBatchSize = 1000; - - _logger.LogInformation("Starting cleanup of pricing audit events older than {RetentionDays} days", RetentionDays); - - try - { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - var cutoffDate = DateTime.UtcNow.AddDays(-RetentionDays); - int totalDeleted = 0; - int batchDeleted; - - do - { - var oldEvents = await context.PricingAuditEvents - .Where(e => e.Timestamp < cutoffDate) - .OrderBy(e => e.Timestamp) - .Take(DeleteBatchSize) - .ToListAsync(); - - if (oldEvents.Count == 0) - break; - - context.PricingAuditEvents.RemoveRange(oldEvents); - await context.SaveChangesAsync(); - - batchDeleted = oldEvents.Count; - totalDeleted += batchDeleted; + #endregion - _logger.LogDebug("Deleted {BatchCount} old pricing audit events", batchDeleted); - - if (batchDeleted == DeleteBatchSize) - await Task.Delay(100); - - } while (batchDeleted == DeleteBatchSize); - - if (totalDeleted > 0) - { - _logger.LogInformation("Cleanup completed: Deleted {TotalDeleted} pricing audit events older than {CutoffDate}", - totalDeleted, cutoffDate); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to cleanup old pricing audit events"); - throw; - } - } - - /// - /// Flushes queued events to the database. - /// - private async Task FlushEventsAsync(bool wait = false) - { - var timeout = wait ? Timeout.InfiniteTimeSpan : TimeSpan.Zero; - if (!await _flushSemaphore.WaitAsync(timeout)) - return; - - try - { - var events = new List(); - - while (events.Count < BatchSize && _eventQueue.TryDequeue(out var auditEvent)) - { - events.Add(auditEvent); - } - - if (events.Count == 0) - return; - - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - await context.PricingAuditEvents.AddRangeAsync(events); - await context.SaveChangesAsync(); - - _logger.LogDebug("Flushed {Count} pricing audit events to database", events.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to flush pricing audit events to database"); - } - finally - { - _flushSemaphore.Release(); - } - } - - /// - /// Timer callback for periodic flushing. - /// - private void FlushEvents(object? state) - { - _ = Task.Run(async () => await FlushEventsAsync()); - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Starting PricingAuditService with batch size {BatchSize} and flush interval {FlushInterval}s", - BatchSize, FlushIntervalSeconds); - - _flushTimer.Change(TimeSpan.FromSeconds(FlushIntervalSeconds), TimeSpan.FromSeconds(FlushIntervalSeconds)); - - _ = Task.Run(async () => await ScheduleDataRetentionAsync(cancellationToken), cancellationToken); - - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Stopping PricingAuditService, flushing remaining events..."); - - _flushTimer?.Change(Timeout.Infinite, 0); - - await _flushSemaphore.WaitAsync(cancellationToken); - try - { - var events = new List(); - - while (_eventQueue.TryDequeue(out var auditEvent)) - { - events.Add(auditEvent); - } - - if (events.Count > 0) - { - using var scope = _serviceProvider.CreateScope(); - var context = scope.ServiceProvider.GetRequiredService(); - - await context.PricingAuditEvents.AddRangeAsync(events); - await context.SaveChangesAsync(); - - _logger.LogDebug("Final flush of {Count} pricing audit events to database", events.Count); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to flush remaining pricing audit events to database"); - } - finally - { - _flushSemaphore.Release(); - } - } - - /// - public void Dispose() - { - if (_disposed) - return; - - _flushTimer?.Dispose(); - _flushSemaphore?.Dispose(); - _disposed = true; - } - - /// - /// Schedules periodic data retention cleanup. - /// - private async Task ScheduleDataRetentionAsync(CancellationToken cancellationToken) - { - await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); - - while (!cancellationToken.IsCancellationRequested) - { - try - { - await CleanupOldAuditEventsAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during pricing audit event cleanup"); - } - - await Task.Delay(TimeSpan.FromDays(1), cancellationToken); - } - } + #region Private Helpers /// /// Extracts the rule description from serialized rule JSON. @@ -380,4 +166,6 @@ private static string ExtractRuleDescription(string? matchedRuleJson) return "Unnamed Rule"; } + + #endregion } diff --git a/Shared/ConduitLLM.Configuration/Services/ProviderKeyCredentialValidator.cs b/Shared/ConduitLLM.Configuration/Services/ProviderKeyCredentialValidator.cs index 81070bc8f..6d25dc942 100644 --- a/Shared/ConduitLLM.Configuration/Services/ProviderKeyCredentialValidator.cs +++ b/Shared/ConduitLLM.Configuration/Services/ProviderKeyCredentialValidator.cs @@ -1,107 +1,40 @@ +using ConduitLLM.Configuration.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace ConduitLLM.Configuration.Services { /// /// Validates business rules for ProviderKeyCredential operations /// - public class ProviderKeyCredentialValidator + public class ProviderKeyCredentialValidator : CredentialValidatorBase { - private readonly ConduitDbContext _context; - private const int MaxKeysPerProvider = 32; + protected override int MaxPerGroup => 32; + protected override string EntityName => "key"; + protected override string GroupName => "Provider"; + protected override DbSet GetDbSet(ConduitDbContext context) => context.ProviderKeyCredentials; - public ProviderKeyCredentialValidator(ConduitDbContext context) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - } + public ProviderKeyCredentialValidator( + IDbContextFactory dbContextFactory, + ILogger logger) + : base(dbContextFactory, logger) { } /// /// Validates if a new key can be added to a provider /// - public async Task ValidateAddKeyAsync(int ProviderId) - { - var currentKeyCount = await _context.ProviderKeyCredentials - .CountAsync(k => k.ProviderId == ProviderId); - - if (currentKeyCount >= MaxKeysPerProvider) - { - return KeyValidationResult.Failure($"Provider already has the maximum of {MaxKeysPerProvider} keys"); - } - - return KeyValidationResult.Success(); - } - - /// - /// Validates if a key can be set as primary - /// - public async Task ValidateSetPrimaryAsync(int keyId) - { - var key = await _context.ProviderKeyCredentials - .FirstOrDefaultAsync(k => k.Id == keyId); - - if (key == null) - { - return KeyValidationResult.Failure("Key not found"); - } - - if (!key.IsEnabled) - { - return KeyValidationResult.Failure("Cannot set a disabled key as primary"); - } - - return KeyValidationResult.Success(); - } + public Task ValidateAddKeyAsync(int providerId, CancellationToken cancellationToken = default) + => ValidateAddAsync(k => k.ProviderId == providerId, cancellationToken); /// /// Validates if a key can be disabled /// - public async Task ValidateDisableKeyAsync(int keyId) - { - var key = await _context.ProviderKeyCredentials - .FirstOrDefaultAsync(k => k.Id == keyId); - - if (key == null) - { - return KeyValidationResult.Failure("Key not found"); - } - - if (key.IsPrimary) - { - return KeyValidationResult.Failure("Cannot disable a primary key. Set another key as primary first."); - } - - return KeyValidationResult.Success(); - } + public Task ValidateDisableKeyAsync(int keyId, CancellationToken cancellationToken = default) + => ValidateDisableAsync(keyId, cancellationToken); /// /// Ensures at least one key is enabled for a provider /// - public async Task ValidateProviderHasEnabledKeyAsync(int ProviderId) - { - var hasEnabledKey = await _context.ProviderKeyCredentials - .AnyAsync(k => k.ProviderId == ProviderId && k.IsEnabled); - - if (!hasEnabledKey) - { - return KeyValidationResult.Failure("Provider must have at least one enabled key"); - } - - return KeyValidationResult.Success(); - } - } - - public class KeyValidationResult - { - public bool IsValid { get; private set; } - public string? ErrorMessage { get; private set; } - - private KeyValidationResult(bool isValid, string? errorMessage = null) - { - IsValid = isValid; - ErrorMessage = errorMessage; - } - - public static KeyValidationResult Success() => new KeyValidationResult(true); - public static KeyValidationResult Failure(string errorMessage) => new KeyValidationResult(false, errorMessage); + public Task ValidateProviderHasEnabledKeyAsync(int providerId, CancellationToken cancellationToken = default) + => ValidateHasEnabledAsync(k => k.ProviderId == providerId && k.IsEnabled, cancellationToken); } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Configuration/Services/RedisCircuitBreaker.cs b/Shared/ConduitLLM.Configuration/Services/RedisCircuitBreaker.cs index 230cef35e..4564d3f10 100644 --- a/Shared/ConduitLLM.Configuration/Services/RedisCircuitBreaker.cs +++ b/Shared/ConduitLLM.Configuration/Services/RedisCircuitBreaker.cs @@ -128,6 +128,7 @@ public async Task ExecuteAsync(Func> operation) } else { + _logger.LogWarning("Redis operation timed out after {TimeoutMs}ms", _options.OperationTimeoutMilliseconds); throw new TimeoutException($"Redis operation timed out after {_options.OperationTimeoutMilliseconds}ms"); } }); @@ -248,7 +249,7 @@ public async Task TestConnectionAsync() } catch (Exception ex) { - _logger.LogDebug("Redis health check failed: {Error}", ex.Message); + _logger.LogWarning("Redis health check failed: {Error}", ex.Message); return false; } } @@ -267,9 +268,11 @@ private void OnCircuitBreak(Exception exception, TimeSpan duration) _lastTripReason = reason; _logger.LogError( - "Redis circuit breaker opened due to failures. Duration: {Duration}s. Reason: {Reason}", + "Redis circuit breaker opened due to failures. Duration: {Duration}s. Reason: {Reason}. Total failures: {TotalFailures}, Total successes: {TotalSuccesses}", duration.TotalSeconds, - reason); + reason, + Interlocked.Read(ref _totalFailures), + Interlocked.Read(ref _totalSuccesses)); // Reset half-open counters _halfOpenSuccesses = 0; @@ -278,8 +281,15 @@ private void OnCircuitBreak(Exception exception, TimeSpan duration) private void OnCircuitReset() { + var openDuration = _circuitOpenedAt.HasValue + ? (DateTime.UtcNow - _circuitOpenedAt.Value).TotalSeconds + : 0; _currentPollyState = PollyCircuitState.Closed; - _logger.LogInformation("Redis circuit breaker reset to closed state. Service recovered."); + _logger.LogInformation( + "Redis circuit breaker reset to closed state. Service recovered after {OpenDurationSeconds:F0}s. Half-open successes: {HalfOpenSuccesses}/{HalfOpenAttempts}", + openDuration, + _halfOpenSuccesses, + _halfOpenAttempts); _circuitOpenedAt = null; _halfOpenSuccesses = 0; _halfOpenAttempts = 0; @@ -320,23 +330,37 @@ private void OnOperationSuccess() private void OnOperationFailure(Exception ex) { - Interlocked.Increment(ref _totalFailures); + var failures = Interlocked.Increment(ref _totalFailures); _lastFailureAt = DateTime.UtcNow; if (IsHalfOpen) { _halfOpenAttempts++; - + _logger.LogWarning( - "Half-open failure. Attempts: {Attempts}/{Max}", + "Half-open failure. Attempts: {Attempts}/{Max}. Error: {ErrorType}: {ErrorMessage}", _halfOpenAttempts, - _options.HalfOpenMaxAttempts); + _options.HalfOpenMaxAttempts, + ex.GetType().Name, + ex.Message); + } + else + { + _logger.LogWarning( + "Redis operation failed ({ErrorType}): {ErrorMessage}. Total failures: {TotalFailures}", + ex.GetType().Name, + ex.Message, + failures); } } private void OnRequestRejected() { - Interlocked.Increment(ref _rejectedRequests); + var rejected = Interlocked.Increment(ref _rejectedRequests); + _logger.LogWarning( + "Redis request rejected โ€” circuit breaker is open. Total rejected: {RejectedRequests}. Retry after: {RetryAfter}", + rejected, + CalculateTimeUntilHalfOpen()?.TotalSeconds.ToString("F0") ?? "unknown"); } private TimeSpan? CalculateTimeUntilHalfOpen() diff --git a/Shared/ConduitLLM.Configuration/Services/RequestLogService.cs b/Shared/ConduitLLM.Configuration/Services/RequestLogService.cs index ff488138c..59f2fb294 100644 --- a/Shared/ConduitLLM.Configuration/Services/RequestLogService.cs +++ b/Shared/ConduitLLM.Configuration/Services/RequestLogService.cs @@ -1,427 +1,450 @@ using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Utilities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Configuration.Services +namespace ConduitLLM.Configuration.Services; + +/// +/// Service for logging and retrieving API requests made using virtual keys. +/// Uses batch processing for efficient database writes. +/// +public class RequestLogService : BatchAuditServiceBase, IRequestLogService { /// - /// Service for logging and retrieving API requests made using virtual keys + /// Creates a new instance of the RequestLogService. /// - public class RequestLogService : IRequestLogService + /// Service provider for creating scoped DbContexts + /// Logger instance + public RequestLogService( + IServiceProvider serviceProvider, + ILogger logger) + : base(serviceProvider, logger) { - private readonly ConduitDbContext _context; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the RequestLogService - /// - /// Database context - /// Logger instance - public RequestLogService(ConduitDbContext context, ILogger logger) - { - _context = context; - _logger = logger; - } + } - /// - public decimal CalculateCost(string modelName, int inputTokens, int outputTokens) - { - // This is a simplified implementation - in a real system, - // you'd likely have a more sophisticated pricing model - decimal inputRate = 0; - decimal outputRate = 0; + #region Template Method Implementations - // Set rates based on model - switch (modelName.ToLowerInvariant()) - { - case string name when name.Contains("gpt-4"): - inputRate = 0.00001m; // $0.01 per 1K tokens - outputRate = 0.00003m; // $0.03 per 1K tokens - break; - case string name when name.Contains("gpt-3.5"): - inputRate = 0.0000015m; // $0.0015 per 1K tokens - outputRate = 0.000002m; // $0.002 per 1K tokens - break; - default: - inputRate = 0.000001m; // Default rate - outputRate = 0.000002m; // Default rate - break; - } + /// + protected override DbSet GetDbSet(ConduitDbContext context) + => context.RequestLogs; - decimal inputCost = inputTokens * inputRate; - decimal outputCost = outputTokens * outputRate; + /// + protected override string EntityName => "RequestLog"; - return inputCost + outputCost; - } + #endregion + + #region IRequestLogService Implementation + + /// + public async Task LogRequestAsync(LogRequestDto request) + { + try + { + var log = MapToRequestLog(request); + await LogEventAsync(log); - /// - public (int InputTokens, int OutputTokens) EstimateTokens(string requestContent, string responseContent) + Logger.LogDebug("Request logged for VirtualKeyId={VirtualKeyId}, Cost={Cost:C}, ProviderId={ProviderId}, queued for batch write", + request.VirtualKeyId, request.Cost, request.ProviderId); + } + catch (Exception ex) { - // This is a simplified implementation - in a real system, - // you'd likely use a tokenizer like GPT-2/3 BPE + Logger.LogError(ex, + "Error logging request for VirtualKeyId={VirtualKeyId}, Model={Model}, RequestType={RequestType}", + request.VirtualKeyId, + LoggingSanitizer.S(request.ModelName), + LoggingSanitizer.S(request.RequestType)); + throw; + } + } - // Rough estimate: ~4 characters per token for English text - int inputTokens = !string.IsNullOrEmpty(requestContent) - ? (int)Math.Ceiling(requestContent.Length / 4.0) - : 0; + /// + /// Optimized method to log request with batched spend updates. + /// + /// Request log data + /// Batch spend update service + public async Task LogRequestWithBatchedSpendAsync(LogRequestDto request, BatchSpendUpdateService batchSpendService) + { + try + { + var log = MapToRequestLog(request); + await LogEventAsync(log); - int outputTokens = !string.IsNullOrEmpty(responseContent) - ? (int)Math.Ceiling(responseContent.Length / 4.0) - : 0; + // Queue spend update for batching instead of immediate database write + await batchSpendService.QueueSpendUpdateAsync(request.VirtualKeyId, request.Cost); - return (inputTokens, outputTokens); + Logger.LogDebug("Request logged and spend update queued for VirtualKeyId={VirtualKeyId}, Cost={Cost:C}, ProviderId={ProviderId}", + request.VirtualKeyId, request.Cost, request.ProviderId); } - - /// - public async Task GetVirtualKeyIdFromKeyValueAsync(string keyValue) + catch (Exception ex) { - return await _context.VirtualKeys - .AsNoTracking() - .Where(k => k.KeyHash == keyValue) - .Select(k => (int?)k.Id) - .FirstOrDefaultAsync(); + Logger.LogError(ex, + "Error logging request for VirtualKeyId={VirtualKeyId}, Model={Model}, RequestType={RequestType}", + request.VirtualKeyId, + LoggingSanitizer.S(request.ModelName), + LoggingSanitizer.S(request.RequestType)); + throw; } + } + + private static RequestLog MapToRequestLog(LogRequestDto request) => new() + { + VirtualKeyId = request.VirtualKeyId, + ModelName = request.ModelName, + ProviderId = request.ProviderId, + ProviderType = request.ProviderType, + RequestType = request.RequestType, + InputTokens = request.InputTokens, + OutputTokens = request.OutputTokens, + CachedInputTokens = request.CachedInputTokens, + CachedWriteTokens = request.CachedWriteTokens, + Cost = request.Cost, + ResponseTimeMs = request.ResponseTimeMs, + Timestamp = DateTime.UtcNow, + UserId = request.UserId, + ClientIp = request.ClientIp, + RequestPath = request.RequestPath, + StatusCode = request.StatusCode, + Metadata = request.Metadata + }; + + /// + public new Task FlushEventsAsync() + => base.FlushEventsAsync(); + + /// + public Task CleanupOldRequestLogsAsync() + => CleanupOldEventsAsync(); + + #endregion + + #region Query Methods + + /// + public decimal CalculateCost(string modelName, int inputTokens, int outputTokens) + { + // This is a simplified implementation - in a real system, + // you'd likely have a more sophisticated pricing model + decimal inputRate = 0; + decimal outputRate = 0; - /// - public async Task GetUsageStatisticsAsync(int virtualKeyId, DateTime startDate, DateTime endDate) + // Set rates based on model + switch (modelName.ToLowerInvariant()) { - // Use projection to avoid loading the entire entities into memory - var result = new UsageStatisticsDto(); + case string name when name.Contains("gpt-4"): + inputRate = 0.00001m; // $0.01 per 1K tokens + outputRate = 0.00003m; // $0.03 per 1K tokens + break; + case string name when name.Contains("gpt-3.5"): + inputRate = 0.0000015m; // $0.0015 per 1K tokens + outputRate = 0.000002m; // $0.002 per 1K tokens + break; + default: + inputRate = 0.000001m; // Default rate + outputRate = 0.000002m; // Default rate + break; + } + + decimal inputCost = inputTokens * inputRate; + decimal outputCost = outputTokens * outputRate; + + return inputCost + outputCost; + } + + /// + public (int InputTokens, int OutputTokens) EstimateTokens(string requestContent, string responseContent) + { + // This is a simplified implementation - in a real system, + // you'd likely use a tokenizer like GPT-2/3 BPE + + // Rough estimate: ~4 characters per token for English text + int inputTokens = !string.IsNullOrEmpty(requestContent) + ? (int)Math.Ceiling(requestContent.Length / 4.0) + : 0; + + int outputTokens = !string.IsNullOrEmpty(responseContent) + ? (int)Math.Ceiling(responseContent.Length / 4.0) + : 0; - var stats = await _context.RequestLogs + return (inputTokens, outputTokens); + } + + /// + public async Task GetVirtualKeyIdFromKeyValueAsync(string keyValue) + { + using var scope = ServiceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.VirtualKeys + .AsNoTracking() + .Where(k => k.KeyHash == keyValue) + .Select(k => (int?)k.Id) + .FirstOrDefaultAsync(); + } + + /// + public async Task GetUsageStatisticsAsync(int virtualKeyId, DateTime startDate, DateTime endDate) + { + using var scope = ServiceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // Use projection to avoid loading the entire entities into memory + var result = new UsageStatisticsDto(); + + var stats = await context.RequestLogs + .AsNoTracking() + .Where(r => r.VirtualKeyId == virtualKeyId) + .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate) + .GroupBy(r => 1) + .Select(g => new + { + TotalRequests = g.Count(), + TotalCost = g.Sum(r => r.Cost), + TotalInputTokens = g.Sum(r => r.InputTokens), + TotalOutputTokens = g.Sum(r => r.OutputTokens), + AverageResponseTime = g.Any() ? g.Average(r => r.ResponseTimeMs) : 0 + }) + .FirstOrDefaultAsync(); + + if (stats != null) + { + result.TotalRequests = stats.TotalRequests; + result.TotalCost = stats.TotalCost; + result.TotalInputTokens = stats.TotalInputTokens; + result.TotalOutputTokens = stats.TotalOutputTokens; + result.AverageResponseTimeMs = stats.AverageResponseTime; + + // Get model-specific usage statistics + var modelStats = await context.RequestLogs .AsNoTracking() .Where(r => r.VirtualKeyId == virtualKeyId) .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate) - .GroupBy(r => 1) + .GroupBy(r => r.ModelName) .Select(g => new { - TotalRequests = g.Count(), - TotalCost = g.Sum(r => r.Cost), - TotalInputTokens = g.Sum(r => r.InputTokens), - TotalOutputTokens = g.Sum(r => r.OutputTokens), - AverageResponseTime = g.Count() > 0 ? g.Average(r => r.ResponseTimeMs) : 0 + ModelName = g.Key, + RequestCount = g.Count(), + Cost = g.Sum(r => r.Cost), + InputTokens = g.Sum(r => r.InputTokens), + OutputTokens = g.Sum(r => r.OutputTokens) }) - .FirstOrDefaultAsync(); + .ToListAsync(); - if (stats != null) + foreach (var modelStat in modelStats) { - result.TotalRequests = stats.TotalRequests; - result.TotalCost = stats.TotalCost; - result.TotalInputTokens = stats.TotalInputTokens; - result.TotalOutputTokens = stats.TotalOutputTokens; - result.AverageResponseTimeMs = stats.AverageResponseTime; - - // Get model-specific usage statistics - var modelStats = await _context.RequestLogs - .AsNoTracking() - .Where(r => r.VirtualKeyId == virtualKeyId) - .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate) - .GroupBy(r => r.ModelName) - .Select(g => new - { - ModelName = g.Key, - RequestCount = g.Count(), - Cost = g.Sum(r => r.Cost), - InputTokens = g.Sum(r => r.InputTokens), - OutputTokens = g.Sum(r => r.OutputTokens) - }) - .ToListAsync(); - - foreach (var modelStat in modelStats) + result.ModelUsage[modelStat.ModelName] = new ModelUsage { - result.ModelUsage[modelStat.ModelName] = new ModelUsage - { - RequestCount = modelStat.RequestCount, - Cost = modelStat.Cost, - InputTokens = modelStat.InputTokens, - OutputTokens = modelStat.OutputTokens - }; - } + RequestCount = modelStat.RequestCount, + Cost = modelStat.Cost, + InputTokens = modelStat.InputTokens, + OutputTokens = modelStat.OutputTokens + }; } - - return result; } - /// - public async Task LogRequestAsync(LogRequestDto request) - { - try - { - var log = new RequestLog - { - VirtualKeyId = request.VirtualKeyId, - ModelName = request.ModelName, - ProviderId = request.ProviderId, - ProviderType = request.ProviderType, - RequestType = request.RequestType, - InputTokens = request.InputTokens, - OutputTokens = request.OutputTokens, - Cost = request.Cost, - ResponseTimeMs = request.ResponseTimeMs, - Timestamp = DateTime.UtcNow, - UserId = request.UserId, - ClientIp = request.ClientIp, - RequestPath = request.RequestPath, - StatusCode = request.StatusCode, - Metadata = request.Metadata - }; + return result; + } - _context.RequestLogs.Add(log); - await _context.SaveChangesAsync(); + /// + /// Gets paged request logs for a virtual key + /// + /// The virtual key ID + /// Page number (1-based) + /// Page size + /// Paged list of request logs + public async Task<(List Logs, int TotalCount)> GetPagedRequestLogsAsync( + int virtualKeyId, + int pageNumber = 1, + int pageSize = 20) + { + using var scope = ServiceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); - // OPTIMIZATION: Use batch spend update service instead of immediate database write - // This reduces database load from O(n) writes per request to batch updates every 30 seconds - _logger.LogDebug("Request logged for VirtualKeyId={VirtualKeyId}, Cost={Cost:C}, ProviderId={ProviderId}, queuing spend update", - request.VirtualKeyId, request.Cost, request.ProviderId); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error logging request for VirtualKeyId={VirtualKeyId}, Model={Model}, RequestType={RequestType}", - request.VirtualKeyId, - LoggingSanitizer.S(request.ModelName), - LoggingSanitizer.S(request.RequestType)); - throw; - } - } + var query = context.RequestLogs + .AsNoTracking() + .Where(r => r.VirtualKeyId == virtualKeyId) + .OrderByDescending(r => r.Timestamp); - /// - /// Optimized method to log request with batched spend updates - /// - /// Request log data - /// Batch spend update service - /// Async task - public async Task LogRequestWithBatchedSpendAsync(LogRequestDto request, BatchSpendUpdateService batchSpendService) - { - try - { - var log = new RequestLog - { - VirtualKeyId = request.VirtualKeyId, - ModelName = request.ModelName, - ProviderId = request.ProviderId, - ProviderType = request.ProviderType, - RequestType = request.RequestType, - InputTokens = request.InputTokens, - OutputTokens = request.OutputTokens, - Cost = request.Cost, - ResponseTimeMs = request.ResponseTimeMs, - Timestamp = DateTime.UtcNow, - UserId = request.UserId, - ClientIp = request.ClientIp, - RequestPath = request.RequestPath, - StatusCode = request.StatusCode, - Metadata = request.Metadata - }; + var totalCount = await query.CountAsync(); - _context.RequestLogs.Add(log); - await _context.SaveChangesAsync(); + var logs = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); - // Queue spend update for batching instead of immediate database write - batchSpendService.QueueSpendUpdate(request.VirtualKeyId, request.Cost); + return (logs, totalCount); + } - _logger.LogDebug("Request logged and spend update queued for VirtualKeyId={VirtualKeyId}, Cost={Cost:C}, ProviderId={ProviderId}", - request.VirtualKeyId, request.Cost, request.ProviderId); + /// + public async Task<(List Logs, int TotalCount)> SearchLogsAsync( + int? virtualKeyId, + string? modelFilter, + DateTime startDate, + DateTime endDate, + int? statusCode, + int pageNumber = 1, + int pageSize = 20) + { + try + { + using var scope = ServiceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var query = context.RequestLogs + .AsNoTracking() + .Include(r => r.VirtualKey) + .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate); + + // Apply optional filters + if (virtualKeyId.HasValue) + { + query = query.Where(r => r.VirtualKeyId == virtualKeyId.Value); } - catch (Exception ex) + + if (!string.IsNullOrWhiteSpace(modelFilter)) { - _logger.LogError(ex, - "Error logging request for VirtualKeyId={VirtualKeyId}, Model={Model}, RequestType={RequestType}", - request.VirtualKeyId, - LoggingSanitizer.S(request.ModelName), - LoggingSanitizer.S(request.RequestType)); - throw; + query = query.Where(r => r.ModelName.Contains(modelFilter)); } - } - /// - /// Gets paged request logs for a virtual key - /// - /// The virtual key ID - /// Page number (1-based) - /// Page size - /// Paged list of request logs - public async Task<(List Logs, int TotalCount)> GetPagedRequestLogsAsync( - int virtualKeyId, - int pageNumber = 1, - int pageSize = 20) - { - var query = _context.RequestLogs - .AsNoTracking() - .Where(r => r.VirtualKeyId == virtualKeyId) - .OrderByDescending(r => r.Timestamp); + if (statusCode.HasValue) + { + query = query.Where(r => r.StatusCode == statusCode.Value); + } + // Get total count before pagination var totalCount = await query.CountAsync(); + // Apply sorting and pagination var logs = await query + .OrderByDescending(r => r.Timestamp) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (logs, totalCount); } + catch (Exception ex) + { + Logger.LogError(ex, + "Error searching request logs with filters: VirtualKeyId={VirtualKeyId}, ModelFilter={ModelFilter}, " + + "StatusCode={StatusCode}, StartDate={StartDate}, EndDate={EndDate}", + virtualKeyId, modelFilter, statusCode, startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd")); + throw; + } + } - /// - public async Task<(List Logs, int TotalCount)> SearchLogsAsync( - int? virtualKeyId, - string? modelFilter, - DateTime startDate, - DateTime endDate, - int? statusCode, - int pageNumber = 1, - int pageSize = 20) + /// + public async Task GetLogsSummaryAsync(DateTime startDate, DateTime endDate) + { + try { - try - { - var query = _context.RequestLogs - .AsNoTracking() - .Include(r => r.VirtualKey) - .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate); + using var scope = ServiceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); - // Apply optional filters - if (virtualKeyId.HasValue) - { - query = query.Where(r => r.VirtualKeyId == virtualKeyId.Value); - } + var logs = await context.RequestLogs + .AsNoTracking() + .Include(r => r.VirtualKey) + .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate) + .ToListAsync(); - if (!string.IsNullOrWhiteSpace(modelFilter)) + var summary = new LogsSummaryDto + { + TotalRequests = logs.Count, + EstimatedCost = logs.Sum(r => r.Cost), + InputTokens = logs.Sum(r => r.InputTokens), + OutputTokens = logs.Sum(r => r.OutputTokens), + AverageResponseTime = logs.Count > 0 ? logs.Average(r => r.ResponseTimeMs) : 0, + LastRequestDate = logs.Count > 0 ? logs.Max(r => r.Timestamp) : null + }; + + // Group by model + var modelGroups = logs + .GroupBy(r => r.ModelName) + .Select(g => new { - query = query.Where(r => r.ModelName.Contains(modelFilter)); - } + ModelName = g.Key, + RequestCount = g.Count(), + TotalCost = g.Sum(r => r.Cost), + InputTokens = g.Sum(r => r.InputTokens), + OutputTokens = g.Sum(r => r.OutputTokens) + }) + .OrderByDescending(g => g.RequestCount) + .ToList(); - if (statusCode.HasValue) - { - query = query.Where(r => r.StatusCode == statusCode.Value); - } + foreach (var model in modelGroups) + { + summary.RequestsByModel[model.ModelName] = model.RequestCount; + summary.CostByModel[model.ModelName] = model.TotalCost; + } - // Get total count before pagination - var totalCount = await query.CountAsync(); + // Calculate success and failure counts + summary.SuccessfulRequests = logs.Count(r => r.StatusCode.HasValue && r.StatusCode >= 200 && r.StatusCode < 300); + summary.FailedRequests = logs.Count(r => r.StatusCode.HasValue && (r.StatusCode < 200 || r.StatusCode >= 300)); - // Apply sorting and pagination - var logs = await query - .OrderByDescending(r => r.Timestamp) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); + // Group by status + var statusGroups = logs + .Where(r => r.StatusCode.HasValue) + .GroupBy(r => r.StatusCode!.Value) + .Select(g => new { StatusCode = g.Key, Count = g.Count() }) + .ToList(); - return (logs, totalCount); - } - catch (Exception ex) + foreach (var status in statusGroups) { - _logger.LogError(ex, - "Error searching request logs with filters: VirtualKeyId={VirtualKeyId}, ModelFilter={ModelFilter}, " + - "StatusCode={StatusCode}, StartDate={StartDate}, EndDate={EndDate}", - virtualKeyId, modelFilter, statusCode, startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd")); - throw; + summary.RequestsByStatus[status.StatusCode] = status.Count; } - } - /// - public async Task GetLogsSummaryAsync(DateTime startDate, DateTime endDate) - { - try - { - var logs = await _context.RequestLogs - .AsNoTracking() - .Include(r => r.VirtualKey) - .Where(r => r.Timestamp >= startDate && r.Timestamp <= endDate) - .ToListAsync(); - - var summary = new LogsSummaryDto + // Group by day and model for daily stats + var dailyStats = logs + .GroupBy(r => new { Date = r.Timestamp.Date, Model = r.ModelName }) + .Select(g => new DailyUsageStatsDto { - TotalRequests = logs.Count, - EstimatedCost = logs.Sum(r => r.Cost), - InputTokens = logs.Sum(r => r.InputTokens), - OutputTokens = logs.Sum(r => r.OutputTokens), - AverageResponseTime = logs.Count() > 0 ? logs.Average(r => r.ResponseTimeMs) : 0, - LastRequestDate = logs.Count() > 0 ? logs.Max(r => r.Timestamp) : null - }; + Date = g.Key.Date, + ModelId = g.Key.Model, + RequestCount = g.Count(), + InputTokens = g.Sum(r => r.InputTokens), + OutputTokens = g.Sum(r => r.OutputTokens), + Cost = g.Sum(r => r.Cost) + }) + .OrderBy(s => s.Date) + .ThenBy(s => s.ModelId) + .ToList(); - // Group by model - var modelGroups = logs - .GroupBy(r => r.ModelName) - .Select(g => new - { - ModelName = g.Key, - RequestCount = g.Count(), - TotalCost = g.Sum(r => r.Cost), - InputTokens = g.Sum(r => r.InputTokens), - OutputTokens = g.Sum(r => r.OutputTokens) - }) - .OrderByDescending(g => g.RequestCount) - .ToList(); - - foreach (var model in modelGroups) - { - summary.RequestsByModel[model.ModelName] = model.RequestCount; - summary.CostByModel[model.ModelName] = model.TotalCost; - } - - // Calculate success and failure counts - summary.SuccessfulRequests = logs.Count(r => r.StatusCode.HasValue && r.StatusCode >= 200 && r.StatusCode < 300); - summary.FailedRequests = logs.Count(r => r.StatusCode.HasValue && (r.StatusCode < 200 || r.StatusCode >= 300)); - - // Group by status - var statusGroups = logs - .Where(r => r.StatusCode.HasValue) - .GroupBy(r => r.StatusCode!.Value) - .Select(g => new { StatusCode = g.Key, Count = g.Count() }) - .ToList(); - - foreach (var status in statusGroups) - { - summary.RequestsByStatus[status.StatusCode] = status.Count; - } - - // Group by day and model for daily stats - var dailyStats = logs - .GroupBy(r => new { Date = r.Timestamp.Date, Model = r.ModelName }) - .Select(g => new DailyUsageStatsDto - { - Date = g.Key.Date, - ModelId = g.Key.Model, - RequestCount = g.Count(), - InputTokens = g.Sum(r => r.InputTokens), - OutputTokens = g.Sum(r => r.OutputTokens), - Cost = g.Sum(r => r.Cost) - }) - .OrderBy(s => s.Date) - .ThenBy(s => s.ModelId) - .ToList(); - - summary.DailyStats = dailyStats; - - return summary; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting logs summary for period {StartDate} to {EndDate}", - startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd")); - throw; - } + summary.DailyStats = dailyStats; + + return summary; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting logs summary for period {StartDate} to {EndDate}", + startDate.ToString("yyyy-MM-dd"), endDate.ToString("yyyy-MM-dd")); + throw; } + } - /// - public async Task> GetDistinctModelsAsync() + /// + public async Task> GetDistinctModelsAsync() + { + try { - try - { - return await _context.RequestLogs - .AsNoTracking() - .Select(r => r.ModelName) - .Distinct() - .OrderBy(m => m) - .ToListAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error retrieving distinct model names from request logs"); - throw; - } + using var scope = ServiceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.RequestLogs + .AsNoTracking() + .Select(r => r.ModelName) + .Distinct() + .OrderBy(m => m) + .ToListAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error retrieving distinct model names from request logs"); + throw; } } + + #endregion } diff --git a/Shared/ConduitLLM.Configuration/Services/ValidationResult.cs b/Shared/ConduitLLM.Configuration/Services/ValidationResult.cs new file mode 100644 index 000000000..bc04ad363 --- /dev/null +++ b/Shared/ConduitLLM.Configuration/Services/ValidationResult.cs @@ -0,0 +1,20 @@ +namespace ConduitLLM.Configuration.Services; + +/// +/// Represents the result of a validation operation. +/// Used by validators that check business rules before mutations. +/// +public class ValidationResult +{ + public bool IsValid { get; private set; } + public string? ErrorMessage { get; private set; } + + private ValidationResult(bool isValid, string? errorMessage = null) + { + IsValid = isValid; + ErrorMessage = errorMessage; + } + + public static ValidationResult Success() => new(true); + public static ValidationResult Failure(string errorMessage) => new(false, errorMessage); +} diff --git a/Shared/ConduitLLM.Configuration/Services/VirtualKeyMaintenanceService.cs b/Shared/ConduitLLM.Configuration/Services/VirtualKeyMaintenanceService.cs index f8c2fa27d..aaa1c09af 100644 --- a/Shared/ConduitLLM.Configuration/Services/VirtualKeyMaintenanceService.cs +++ b/Shared/ConduitLLM.Configuration/Services/VirtualKeyMaintenanceService.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Configuration.Services { @@ -49,18 +50,20 @@ public async Task DisableExpiredKeysAsync() var now = DateTime.UtcNow; // Get all active keys with expiration dates that have passed - var allKeys = await _virtualKeyRepository.GetAllAsync(); + var allKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _virtualKeyRepository.GetPaginatedAsync); var expiredKeys = allKeys .Where(k => k.IsEnabled) .Where(k => k.ExpiresAt.HasValue && k.ExpiresAt.Value < now) .ToList(); - if (expiredKeys.Count() == 0) + if (!expiredKeys.Any()) { + _logger.LogDebug("No expired virtual keys found during maintenance check"); return; } - _logger.LogInformation("Disabling {Count} expired virtual keys", expiredKeys.Count()); + _logger.LogInformation("Disabling {Count} expired virtual keys", expiredKeys.Count); // Update keys to disable them foreach (var key in expiredKeys) @@ -69,9 +72,11 @@ public async Task DisableExpiredKeysAsync() key.UpdatedAt = now; await _virtualKeyRepository.UpdateAsync(key); + _logger.LogDebug("Disabled expired virtual key {KeyId} ({KeyName}), expired at {ExpiresAt}", + key.Id, key.KeyName, key.ExpiresAt); } - _logger.LogInformation("Successfully disabled {Count} expired virtual keys", expiredKeys.Count()); + _logger.LogInformation("Successfully disabled {Count} expired virtual keys", expiredKeys.Count); } catch (Exception ex) { diff --git a/Shared/ConduitLLM.Configuration/Services/VirtualKeyService.cs b/Shared/ConduitLLM.Configuration/Services/VirtualKeyService.cs deleted file mode 100644 index 2e75b3966..000000000 --- a/Shared/ConduitLLM.Configuration/Services/VirtualKeyService.cs +++ /dev/null @@ -1,182 +0,0 @@ -using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Interfaces; - -using Microsoft.EntityFrameworkCore; - -namespace ConduitLLM.Configuration.Services -{ - /// - /// Service for managing virtual keys - /// - public class VirtualKeyService : IVirtualKeyService - { - private readonly ConduitDbContext _context; - private readonly IVirtualKeyGroupRepository _groupRepository; - - /// - /// Initializes a new instance of the VirtualKeyService - /// - /// Database context - /// Virtual key group repository - public VirtualKeyService(ConduitDbContext context, IVirtualKeyGroupRepository groupRepository) - { - _context = context; - _groupRepository = groupRepository; - } - - /// - public async Task CreateVirtualKeyAsync(VirtualKey virtualKey) - { - // Generate a unique key value if one wasn't provided - if (string.IsNullOrEmpty(virtualKey.KeyHash)) - { - virtualKey.KeyHash = $"vk_{Guid.NewGuid().ToString("N").Substring(0, 16)}"; - } - - // Set creation date if not provided - if (virtualKey.CreatedAt == default) - { - virtualKey.CreatedAt = DateTime.UtcNow; - } - - // Set update date - virtualKey.UpdatedAt = DateTime.UtcNow; - - // If no group is assigned, create a new single-key group - if (virtualKey.VirtualKeyGroupId == 0) - { - var group = new VirtualKeyGroup - { - GroupName = virtualKey.KeyName, - Balance = 0, // Start with zero balance, user needs to add credits - LifetimeCreditsAdded = 0, - LifetimeSpent = 0 - }; - - virtualKey.VirtualKeyGroupId = await _groupRepository.CreateAsync(group); - } - - _context.VirtualKeys.Add(virtualKey); - await _context.SaveChangesAsync(); - - return virtualKey; - } - - /// - public async Task DeleteVirtualKeyAsync(int id) - { - var virtualKey = await _context.VirtualKeys.FindAsync(id); - if (virtualKey != null) - { - _context.VirtualKeys.Remove(virtualKey); - await _context.SaveChangesAsync(); - } - } - - /// - public async Task> GetAllVirtualKeysAsync() - { - return await _context.VirtualKeys.ToListAsync(); - } - - /// - public async Task GetVirtualKeyByIdAsync(int id) - { - return await _context.VirtualKeys.FindAsync(id); - } - - /// - public async Task GetVirtualKeyByKeyValueAsync(string keyValue) - { - return await _context.VirtualKeys - .FirstOrDefaultAsync(k => k.KeyHash == keyValue); - } - - /// - public async Task ResetSpendAsync(int id) - { - // Budget tracking is now at the group level - // This method is deprecated but kept for interface compatibility - await Task.CompletedTask; - } - - /// - public async Task UpdateVirtualKeyAsync(VirtualKey virtualKey) - { - virtualKey.UpdatedAt = DateTime.UtcNow; - _context.VirtualKeys.Update(virtualKey); - await _context.SaveChangesAsync(); - - return virtualKey; - } - - /// - public async Task UpdateSpendAsync(int id, decimal additionalSpend) - { - // Spending is now tracked at the group level - // This method is deprecated but kept for interface compatibility - var group = await _groupRepository.GetByKeyIdAsync(id); - if (group != null) - { - await _groupRepository.AdjustBalanceAsync(group.Id, -additionalSpend); - } - } - - /// - public async Task ValidateVirtualKeyForAuthenticationAsync(string keyValue, string? requestedModel = null) - { - var virtualKey = await _context.VirtualKeys - .Include(k => k.VirtualKeyGroup) - .FirstOrDefaultAsync(k => k.KeyHash == keyValue); - - if (virtualKey == null) - { - return null; // Key doesn't exist - } - - if (!virtualKey.IsEnabled) - { - return null; // Key is disabled - } - - if (virtualKey.ExpiresAt.HasValue && virtualKey.ExpiresAt.Value < DateTime.UtcNow) - { - return null; // Key is expired - } - - // For authentication, we don't check balance - return virtualKey; // Key is valid for authentication - } - - /// - public async Task ValidateVirtualKeyAsync(string keyValue) - { - var virtualKey = await _context.VirtualKeys - .Include(k => k.VirtualKeyGroup) - .FirstOrDefaultAsync(k => k.KeyHash == keyValue); - - if (virtualKey == null) - { - return false; // Key doesn't exist - } - - if (!virtualKey.IsEnabled) - { - return false; // Key is disabled - } - - if (virtualKey.ExpiresAt.HasValue && virtualKey.ExpiresAt.Value < DateTime.UtcNow) - { - return false; // Key is expired - } - - // Check group balance - if (virtualKey.VirtualKeyGroup != null && virtualKey.VirtualKeyGroup.Balance <= 0) - { - return false; // No balance available - } - - return true; // Key is valid - } - } -} diff --git a/Shared/ConduitLLM.Configuration/Utilities/LogSanitizer.cs b/Shared/ConduitLLM.Configuration/Utilities/LogSanitizer.cs index cf474057d..5da8f9186 100644 --- a/Shared/ConduitLLM.Configuration/Utilities/LogSanitizer.cs +++ b/Shared/ConduitLLM.Configuration/Utilities/LogSanitizer.cs @@ -5,6 +5,11 @@ namespace ConduitLLM.Configuration.Utilities /// /// Utility class for sanitizing user input before logging to prevent log injection attacks. /// + /// + /// This class is obsolete. Use instead, which provides + /// the same functionality with a more complete API including support for additional types. + /// + [Obsolete("Use LoggingSanitizer instead. This class will be removed in a future version.")] public static class LogSanitizer { // Regex patterns for dangerous characters diff --git a/Shared/ConduitLLM.Configuration/Utilities/RedisUrlParser.cs b/Shared/ConduitLLM.Configuration/Utilities/RedisUrlParser.cs index 5a9578116..fbb401f89 100644 --- a/Shared/ConduitLLM.Configuration/Utilities/RedisUrlParser.cs +++ b/Shared/ConduitLLM.Configuration/Utilities/RedisUrlParser.cs @@ -5,6 +5,32 @@ namespace ConduitLLM.Configuration.Utilities /// public static class RedisUrlParser { + /// + /// Resolves the Redis connection string from environment variables. + /// Checks REDIS_URL first (parsing it into a StackExchange.Redis format), + /// then falls back to CONDUIT_REDIS_CONNECTION_STRING. + /// + /// The resolved connection string, or null if neither variable is set. + public static string? ResolveConnectionString() + { + var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); + var redisConnectionString = Environment.GetEnvironmentVariable("CONDUIT_REDIS_CONNECTION_STRING"); + + if (!string.IsNullOrEmpty(redisUrl)) + { + try + { + redisConnectionString = ParseRedisUrl(redisUrl); + } + catch + { + // Failed to parse REDIS_URL, fall back to CONDUIT_REDIS_CONNECTION_STRING + } + } + + return redisConnectionString; + } + /// /// Parses a Redis URL into a StackExchange.Redis compatible connection string /// diff --git a/Services/ConduitLLM.Gateway/Services/VirtualKeyUtilities.cs b/Shared/ConduitLLM.Configuration/Utilities/VirtualKeyUtilities.cs similarity index 86% rename from Services/ConduitLLM.Gateway/Services/VirtualKeyUtilities.cs rename to Shared/ConduitLLM.Configuration/Utilities/VirtualKeyUtilities.cs index 7d890d8c0..2baa0e5bc 100644 --- a/Services/ConduitLLM.Gateway/Services/VirtualKeyUtilities.cs +++ b/Shared/ConduitLLM.Configuration/Utilities/VirtualKeyUtilities.cs @@ -3,7 +3,7 @@ using ConduitLLM.Configuration.DTOs.VirtualKey; using ConduitLLM.Configuration.Entities; -namespace ConduitLLM.Gateway.Services +namespace ConduitLLM.Configuration.Utilities { /// /// Static utility methods for virtual key operations @@ -20,7 +20,7 @@ public static string HashKey(string key) using var sha256 = SHA256.Create(); var bytes = Encoding.UTF8.GetBytes(key); var hash = sha256.ComputeHash(bytes); - + // Convert to hex string to match Admin API format var builder = new StringBuilder(); foreach (byte b in hash) @@ -90,7 +90,7 @@ public static VirtualKeyDto MapToDto(VirtualKey virtualKey) { Id = virtualKey.Id, KeyName = virtualKey.KeyName, - KeyPrefix = "condt_****", // Don't expose the actual key + KeyPrefix = GenerateKeyPrefix(virtualKey.KeyHash), AllowedModels = virtualKey.AllowedModels, VirtualKeyGroupId = virtualKey.VirtualKeyGroupId, IsEnabled = virtualKey.IsEnabled, @@ -103,5 +103,20 @@ public static VirtualKeyDto MapToDto(VirtualKey virtualKey) Description = virtualKey.Description, }; } + + /// + /// Generates a masked key prefix for display purposes using the hash + /// + private static string GenerateKeyPrefix(string keyHash) + { + if (string.IsNullOrEmpty(keyHash)) + { + return "condt_******..."; + } + + var prefixLength = Math.Min(6, keyHash.Length); + var shortPrefix = keyHash.Substring(0, prefixLength).ToLower(); + return $"condt_{shortPrefix}..."; + } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Core/Caching/CacheMetricsService.cs b/Shared/ConduitLLM.Core/Caching/CacheMetricsService.cs index c488e5b0b..e7ab16e9d 100644 --- a/Shared/ConduitLLM.Core/Caching/CacheMetricsService.cs +++ b/Shared/ConduitLLM.Core/Caching/CacheMetricsService.cs @@ -192,7 +192,7 @@ public void ImportStats(long hits, long misses, double avgResponseTimeMs, Interlocked.Exchange(ref _totalRetrievalTimeMs, totalTime); // Import model-specific metrics if provided - if (modelMetrics != null && modelMetrics.Count() > 0) + if (modelMetrics != null && modelMetrics.Any()) { foreach (var kvp in modelMetrics) { diff --git a/Shared/ConduitLLM.Core/Caching/CachingLLMClient.cs b/Shared/ConduitLLM.Core/Caching/CachingLLMClient.cs index 0c2cf9176..c785a0d46 100644 --- a/Shared/ConduitLLM.Core/Caching/CachingLLMClient.cs +++ b/Shared/ConduitLLM.Core/Caching/CachingLLMClient.cs @@ -250,7 +250,7 @@ private string GenerateCacheKey(ChatCompletionRequest request, string? apiKey) var options = _cacheOptions.CurrentValue; // Check model-specific rules first - if (options.ModelSpecificRules != null && options.ModelSpecificRules.Count() > 0) + if (options.ModelSpecificRules != null && options.ModelSpecificRules.Any()) { foreach (var rule in options.ModelSpecificRules) { diff --git a/Shared/ConduitLLM.Core/Caching/CachingServiceExtensions.cs b/Shared/ConduitLLM.Core/Caching/CachingServiceExtensions.cs index 093924a38..08ed36fc7 100644 --- a/Shared/ConduitLLM.Core/Caching/CachingServiceExtensions.cs +++ b/Shared/ConduitLLM.Core/Caching/CachingServiceExtensions.cs @@ -116,18 +116,30 @@ public CachingLLMClientFactory( } /// - public ILLMClient GetClient(string modelAlias) + public IProviderMetadata? GetProviderMetadata(ConduitLLM.Configuration.ProviderType providerType) + { + // Delegate to the inner factory + return _innerFactory.GetProviderMetadata(providerType); + } + + /// + public ILLMClient CreateTestClient(ConduitLLM.Configuration.Entities.Provider provider, ConduitLLM.Configuration.Entities.ProviderKeyCredential keyCredential) + { + // For test clients, we don't wrap with caching + // Test clients are used for authentication verification and should always hit the actual provider + return _innerFactory.CreateTestClient(provider, keyCredential); + } + + /// + public async Task GetClientAsync(string modelAlias, CancellationToken cancellationToken = default) { // Get the original client from the inner factory - var client = _innerFactory.GetClient(modelAlias); + var client = await _innerFactory.GetClientAsync(modelAlias, cancellationToken); // Always wrap the client - the wrapper checks LLMCachingEnabled at runtime - // This allows runtime toggling without recreating clients if (_cacheOptions.CurrentValue.IsEnabled) { var logger = _loggerFactory.CreateLogger(); - - // Wrap the client with the caching decorator return new CachingLLMClient( client, _cacheManager, @@ -137,24 +149,19 @@ public ILLMClient GetClient(string modelAlias) logger); } - // Fall back to the original client if caching is disabled return client; } - /// - public ILLMClient GetClientByProviderId(int providerId) + public async Task GetClientByProviderIdAsync(int providerId, CancellationToken cancellationToken = default) { // Get the original client from the inner factory - var client = _innerFactory.GetClientByProviderId(providerId); + var client = await _innerFactory.GetClientByProviderIdAsync(providerId, cancellationToken); // Always wrap the client - the wrapper checks LLMCachingEnabled at runtime - // This allows runtime toggling without recreating clients if (_cacheOptions.CurrentValue.IsEnabled) { var logger = _loggerFactory.CreateLogger(); - - // Wrap the client with the caching decorator return new CachingLLMClient( client, _cacheManager, @@ -164,30 +171,19 @@ public ILLMClient GetClientByProviderId(int providerId) logger); } - // Fall back to the original client if caching is disabled return client; } /// - public IProviderMetadata? GetProviderMetadata(ConduitLLM.Configuration.ProviderType providerType) - { - // Delegate to the inner factory - return _innerFactory.GetProviderMetadata(providerType); - } - - /// - public ILLMClient GetClientByProviderType(ConduitLLM.Configuration.ProviderType providerType) + public async Task GetClientByProviderTypeAsync(ConduitLLM.Configuration.ProviderType providerType, CancellationToken cancellationToken = default) { // Get the original client from the inner factory - var client = _innerFactory.GetClientByProviderType(providerType); + var client = await _innerFactory.GetClientByProviderTypeAsync(providerType, cancellationToken); // Always wrap the client - the wrapper checks LLMCachingEnabled at runtime - // This allows runtime toggling without recreating clients if (_cacheOptions.CurrentValue.IsEnabled) { var logger = _loggerFactory.CreateLogger(); - - // Wrap the client with the caching decorator return new CachingLLMClient( client, _cacheManager, @@ -197,16 +193,7 @@ public ILLMClient GetClientByProviderType(ConduitLLM.Configuration.ProviderType logger); } - // Fall back to the original client if caching is disabled return client; } - - /// - public ILLMClient CreateTestClient(ConduitLLM.Configuration.Entities.Provider provider, ConduitLLM.Configuration.Entities.ProviderKeyCredential keyCredential) - { - // For test clients, we don't wrap with caching - // Test clients are used for authentication verification and should always hit the actual provider - return _innerFactory.CreateTestClient(provider, keyCredential); - } } } diff --git a/Shared/ConduitLLM.Core/Conduit.cs b/Shared/ConduitLLM.Core/Conduit.cs index 1d3e41ecc..3a6de20dc 100644 --- a/Shared/ConduitLLM.Core/Conduit.cs +++ b/Shared/ConduitLLM.Core/Conduit.cs @@ -87,7 +87,7 @@ public async Task CreateChatCompletionAsync( } // Get the appropriate client from the factory based on the model alias in the request - ILLMClient client = _clientFactory.GetClient(request.Model); + ILLMClient client = await _clientFactory.GetClientAsync(request.Model, cancellationToken); // Call the client's method, passing the optional apiKey // Exceptions specific to providers (like communication errors) are expected to bubble up from the client. @@ -113,7 +113,7 @@ public async IAsyncEnumerable StreamChatCompletionAsync( ChatCompletionRequest request, string? apiKey = null, int? virtualKeyId = null, - Func? onToolExecutingEvent = null, + Func? onToolExecutingEvent = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -139,7 +139,7 @@ public async IAsyncEnumerable StreamChatCompletionAsync( else { // Standard streaming without function calling - ILLMClient client = _clientFactory.GetClient(request.Model); + ILLMClient client = await _clientFactory.GetClientAsync(request.Model, cancellationToken); await foreach (var chunk in client.StreamChatCompletionAsync(request, apiKey, cancellationToken)) { yield return chunk; @@ -193,7 +193,7 @@ private async Task CreateChatCompletionWithFunctionsAsyn var maxIterations = request.MaxAgenticIterations ?? 20; var agenticModeEnabled = request.EnableAgenticMode ?? true; - ILLMClient client = _clientFactory.GetClient(request.Model); + ILLMClient client = await _clientFactory.GetClientAsync(request.Model, cancellationToken); ChatCompletionResponse? response = null; while (iteration < maxIterations) @@ -319,7 +319,7 @@ private async IAsyncEnumerable StreamChatCompletionWithFunc ChatCompletionRequest request, string? apiKey, int virtualKeyId, - Func? onToolExecutingEvent, + Func? onToolExecutingEvent, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { if (_functionDiscoveryService == null || _agenticOrchestrationService == null) @@ -348,7 +348,7 @@ private async IAsyncEnumerable StreamChatCompletionWithFunc var agenticModeEnabled = request.EnableAgenticMode ?? true; var maxIterations = request.MaxAgenticIterations ?? 5; - ILLMClient client = _clientFactory.GetClient(request.Model); + ILLMClient client = await _clientFactory.GetClientAsync(request.Model, cancellationToken); var iteration = 0; // Track tool calls outside the loop for iteration limit check @@ -468,11 +468,11 @@ private async IAsyncEnumerable StreamChatCompletionWithFunc { try { - await onToolExecutingEvent(new + await onToolExecutingEvent(new ToolExecutionEvent { - tool_call_id = toolCall.Id, - function_name = toolCall.Function.Name, - status = "started" + ToolCallId = toolCall.Id, + FunctionName = toolCall.Function.Name, + Status = "started" }, cancellationToken); } catch (Exception ex) @@ -500,14 +500,14 @@ await onToolExecutingEvent(new { try { - await onToolExecutingEvent(new + await onToolExecutingEvent(new ToolExecutionEvent { - tool_call_id = summary.ToolCallId, - function_name = summary.FunctionName, - status = summary.Success ? "completed" : "failed", - cost = summary.Cost, - error_message = summary.ErrorMessage, - function_execution_id = summary.FunctionExecutionId + ToolCallId = summary.ToolCallId, + FunctionName = summary.FunctionName, + Status = summary.Success ? "completed" : "failed", + Cost = summary.Cost, + ErrorMessage = summary.ErrorMessage, + FunctionExecutionId = summary.FunctionExecutionId }, cancellationToken); } catch (Exception ex) @@ -667,7 +667,7 @@ public async Task CreateEmbeddingAsync( throw new ArgumentException("The request must specify a target Model alias.", "request.Model"); // No router for embeddings (OpenAI spec does not support routing for embeddings) - ILLMClient client = _clientFactory.GetClient(request.Model); + ILLMClient client = await _clientFactory.GetClientAsync(request.Model, cancellationToken); return await client.CreateEmbeddingAsync(request, apiKey, cancellationToken).ConfigureAwait(false); } @@ -693,18 +693,19 @@ public async Task CreateImageAsync( throw new ArgumentException("The request must specify a target Model alias.", "request.Model"); // No router for image generation (OpenAI spec does not support routing for images) - ILLMClient client = _clientFactory.GetClient(request.Model); + ILLMClient client = await _clientFactory.GetClientAsync(request.Model, cancellationToken); return await client.CreateImageAsync(request, apiKey, cancellationToken).ConfigureAwait(false); } /// - /// Gets an LLM client for the specified model. + /// Asynchronously gets an LLM client for the specified model. /// /// The model alias to get a client for. + /// A token to cancel the operation. /// The LLM client for the specified model. - public ILLMClient GetClient(string modelAlias) + public async Task GetClientAsync(string modelAlias, CancellationToken cancellationToken = default) { - return _clientFactory.GetClient(modelAlias); + return await _clientFactory.GetClientAsync(modelAlias, cancellationToken); } // Add other high-level methods as needed. diff --git a/Shared/ConduitLLM.Core/ConduitLLM.Core.csproj b/Shared/ConduitLLM.Core/ConduitLLM.Core.csproj index aeb173979..11fe95e7a 100644 --- a/Shared/ConduitLLM.Core/ConduitLLM.Core.csproj +++ b/Shared/ConduitLLM.Core/ConduitLLM.Core.csproj @@ -1,16 +1,21 @@ - - - - - - + + + + + + + + + + + - - - + + + diff --git a/Shared/ConduitLLM.Core/Constants/RedisKeys.cs b/Shared/ConduitLLM.Core/Constants/RedisKeys.cs new file mode 100644 index 000000000..f1706db27 --- /dev/null +++ b/Shared/ConduitLLM.Core/Constants/RedisKeys.cs @@ -0,0 +1,187 @@ +namespace ConduitLLM.Core.Constants; + +/// +/// Centralized Redis key patterns for all operational (non-cache) Redis usage. +/// Covers rate limiting, webhooks, SignalR infrastructure, distributed locks, +/// cache statistics, and spend notifications. +/// +/// For cache-layer keys (virtual keys, model costs, providers, etc.), +/// see . +/// +/// +/// Key naming conventions: +/// - Colons as namespace separators (e.g., "rate:vk:{hash}:rpm") +/// - Lowercase for static parts +/// - Builder methods handle dynamic key construction +/// - Each nested class owns one key namespace to prevent collisions +/// +public static class RedisKeys +{ + #region Rate Limiting + + /// + /// Keys for virtual key rate limiting (sliding window sorted sets). + /// Used by RedisVirtualKeyRateLimitService. + /// + public static class RateLimit + { + public static string VirtualKeyRpm(string hash) => $"rate:vk:{hash}:rpm"; + public static string VirtualKeyRpd(string hash) => $"rate:vk:{hash}:rpd"; + public static string VirtualKeyLimits(string hash) => $"rate:vk:{hash}:limits"; + public static string VirtualKeyRpmSeq(string hash) => $"rate:vk:{hash}:rpm:seq"; + public static string VirtualKeyRpdSeq(string hash) => $"rate:vk:{hash}:rpd:seq"; + + /// Cached rate limit configuration per virtual key. + public static string Config(string hash) => $"rate:config:{hash}"; + } + + /// + /// Keys for SignalR rate limiting and connection tracking. + /// Used by RedisSignalRRateLimitService. + /// + public static class SignalRRateLimit + { + public static string Rpm(string hash) => $"signalr:vk:{hash}:rpm"; + public static string Rpd(string hash) => $"signalr:vk:{hash}:rpd"; + public static string Connections(string hash) => $"signalr:vk:{hash}:connections"; + } + + #endregion + + #region Webhooks + + /// + /// Keys for webhook circuit breaker state. + /// Used by RedisWebhookCircuitBreaker. + /// + public static class WebhookCircuit + { + public static string State(string urlHash) => $"webhook:circuit:{urlHash}:state"; + public static string Failures(string urlHash) => $"webhook:circuit:{urlHash}:failures"; + public static string Successes(string urlHash) => $"webhook:circuit:{urlHash}:success"; + public static string LastFailure(string urlHash) => $"webhook:circuit:{urlHash}:lastfail"; + public static string Opened(string urlHash) => $"webhook:circuit:{urlHash}:opened"; + } + + /// + /// Keys for webhook delivery deduplication and statistics. + /// Used by RedisWebhookDeliveryTracker. + /// + public static class WebhookDelivery + { + public static string Delivered(string deliveryKey) => $"webhook:delivered:{deliveryKey}"; + public static string Stats(string webhookUrl) => $"webhook:stats:{webhookUrl}"; + public static string Failure(string deliveryKey) => $"webhook:failure:{deliveryKey}"; + } + + /// + /// Keys for webhook metrics aggregation. + /// Used by RedisWebhookMetricsService. + /// + public static class WebhookMetrics + { + /// Recent delivery events list. + public const string RecentEvents = "webhook:events:recent"; + + /// Pattern for scanning all URL metrics keys. + public const string UrlMetricsScanPattern = "webhook:metrics:urls:*"; + + public static string UrlMetrics(string urlHash) => $"webhook:metrics:urls:{urlHash}"; + public static string ResponseTimes(string urlHash) => $"webhook:metrics:response:{urlHash}"; + } + + /// + /// Keys for webhook connection tracking (which connections monitor which webhooks). + /// Used by RedisWebhookConnectionTracker. + /// + public static class WebhookConnection + { + public static string ConnectionWebhooks(string connectionId) => $"webhook:connections:{connectionId}:webhooks"; + public static string WebhookConnections(string urlHash) => $"webhook:webhooks:{urlHash}:connections"; + public static string ConnectionTimestamp(string connectionId) => $"webhook:connections:{connectionId}:timestamp"; + } + + #endregion + + #region SignalR Infrastructure + + /// + /// Keys for SignalR infrastructure services (message queuing, batching, monitoring). + /// Used by Gateway SignalR services. + /// + public static class SignalR + { + // Message queue + public const string MessageStream = "signalr:messages"; + public const string DeadLetterStream = "signalr:deadletter"; + + // Acknowledgments + public const string PendingAcknowledgments = "signalr:acknowledgments"; + public const string ConnectionMessagesPrefix = "signalr:conn_msgs"; + + // Connection monitoring + public const string ActiveConnections = "signalr:connections"; + public const string GroupConnectionsPrefix = "signalr:groups"; + + // Message batching + public const string ActiveBatches = "signalr:batches:active"; + public const string BatchQueue = "signalr:batches:queue"; + public const string BatchStatsMethods = "signalr:batches:stats:methods"; + public const string BatchStatsGlobal = "signalr:batches:stats:global"; + } + + #endregion + + #region Distributed Locks + + /// + /// Keys for distributed locking. + /// Used by RedisDistributedLockService. + /// + public static class Lock + { + public static string For(string key) => $"lock:{key}"; + public static string AlertThreshold(string virtualKeyId, string threshold) => $"lock:alert:vk:{virtualKeyId}:threshold:{threshold}"; + } + + #endregion + + #region Cache Statistics + + /// + /// Keys for cache statistics storage (time-series, snapshots). + /// Used by RedisCacheStatisticsStore. + /// + public static class CacheStats + { + public static string Current(string region) => $"cache:stats:{region}:current"; + public static string TimeSeries(string region, DateTime timestamp) => $"cache:stats:ts:{region}:{timestamp:yyyyMMddHHmm}"; + public static string Snapshot(string region, DateTime timestamp) => $"cache:stats:snapshot:{region}:{timestamp:yyyyMMddHH}"; + } + + #endregion + + #region Spend Notifications + + /// + /// Keys for spend notification and alerting. + /// Used by SpendDataRepository. + /// + public static class Spend + { + public const string PatternsPrefix = "spend:patterns"; + public const string SentAlertsPrefix = "spend:alerts:sent"; + public const string CooldownPrefix = "spend:alerts:cooldown"; + public const string HistoryStream = "spend:history:stream"; + public const string NotificationInstances = "spend:notification:instances"; + + public static string Patterns(string virtualKeyId) => $"spend:patterns:{virtualKeyId}"; + public static string PatternsScanPattern() => $"{PatternsPrefix}:*"; + public static string SentAlert(string virtualKeyId, string threshold) => $"spend:alerts:sent:{virtualKeyId}:{threshold}"; + public static string SentAlertScanPattern(string virtualKeyId) => $"spend:alerts:sent:{virtualKeyId}:*"; + public static string Cooldown(string virtualKeyId, string alertType) => $"spend:alerts:cooldown:{virtualKeyId}:{alertType}"; + public static string Instance(string instanceId) => $"spend:notification:instances:{instanceId}"; + } + + #endregion +} diff --git a/Shared/ConduitLLM.Core/Constants/SignalRConstants.cs b/Shared/ConduitLLM.Core/Constants/SignalRConstants.cs index 0ae00db85..6601024d2 100644 --- a/Shared/ConduitLLM.Core/Constants/SignalRConstants.cs +++ b/Shared/ConduitLLM.Core/Constants/SignalRConstants.cs @@ -48,7 +48,8 @@ public static class ClientMethods public const string VideoGenerationProgress = "VideoGenerationProgress"; public const string VideoGenerationCompleted = "VideoGenerationCompleted"; public const string VideoGenerationFailed = "VideoGenerationFailed"; - + public const string VideoGenerationCancelled = "VideoGenerationCancelled"; + // Image generation specific events (legacy - kept for compatibility) public const string ImageGenerationStarted = "ImageGenerationStarted"; public const string ImageGenerationProgress = "ImageGenerationProgress"; diff --git a/Shared/ConduitLLM.Core/Consumers/CacheInvalidationConsumerBase.cs b/Shared/ConduitLLM.Core/Consumers/CacheInvalidationConsumerBase.cs new file mode 100644 index 000000000..52acb0d99 --- /dev/null +++ b/Shared/ConduitLLM.Core/Consumers/CacheInvalidationConsumerBase.cs @@ -0,0 +1,61 @@ +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Consumers; + +/// +/// Base class for simple cache invalidation consumers that follow the pattern: +/// log received โ†’ invalidate cache โ†’ log success/failure โ†’ rethrow on failure. +/// +/// The MassTransit event type to consume. +/// +/// Consumers with more complex logic (multiple caches, nullable caches, multi-event handling, +/// error swallowing) should continue to implement directly. +/// +public abstract class CacheInvalidationConsumerBase : IConsumer + where TEvent : class +{ + protected readonly ILogger Logger; + + protected CacheInvalidationConsumerBase(ILogger logger) + { + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + LogReceived(message); + + try + { + await InvalidateCacheAsync(message); + LogSuccess(message); + } + catch (Exception ex) + { + LogFailure(message, ex); + throw; // Always rethrow for MassTransit retry policy + } + } + + /// + /// Performs the actual cache invalidation for the given event message. + /// + protected abstract Task InvalidateCacheAsync(TEvent message); + + /// + /// Logs that the event was received. + /// + protected abstract void LogReceived(TEvent message); + + /// + /// Logs that cache invalidation succeeded. + /// + protected abstract void LogSuccess(TEvent message); + + /// + /// Logs that cache invalidation failed. + /// + protected abstract void LogFailure(TEvent message, Exception ex); +} diff --git a/Shared/ConduitLLM.Core/Consumers/FunctionConfigurationCacheInvalidationHandler.cs b/Shared/ConduitLLM.Core/Consumers/FunctionConfigurationCacheInvalidationHandler.cs index 1430d518d..be2b4f417 100644 --- a/Shared/ConduitLLM.Core/Consumers/FunctionConfigurationCacheInvalidationHandler.cs +++ b/Shared/ConduitLLM.Core/Consumers/FunctionConfigurationCacheInvalidationHandler.cs @@ -1,6 +1,5 @@ using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; -using MassTransit; using Microsoft.Extensions.Logging; namespace ConduitLLM.Core.Consumers; @@ -11,98 +10,76 @@ namespace ConduitLLM.Core.Consumers; /// /// This ensures cache consistency when function configurations are modified via the Admin API. /// -public class FunctionConfigurationCacheInvalidationHandler : IConsumer +public class FunctionConfigurationCacheInvalidationHandler : CacheInvalidationConsumerBase { private readonly IFunctionDiscoveryCacheService _cacheService; - private readonly ILogger _logger; public FunctionConfigurationCacheInvalidationHandler( IFunctionDiscoveryCacheService cacheService, ILogger logger) + : base(logger) { _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task Consume(ConsumeContext context) - { - var message = context.Message; + protected override Task InvalidateCacheAsync(FunctionConfigurationChanged message) + => _cacheService.InvalidateAllFunctionDiscoveryAsync(); - _logger.LogInformation( + protected override void LogReceived(FunctionConfigurationChanged message) + => Logger.LogInformation( "Received FunctionConfigurationChanged event for '{ConfigName}' (ID: {ConfigId}, Provider: {ProviderType}, ChangeType: {ChangeType})", message.ConfigurationName, message.FunctionConfigurationId, message.ProviderType, message.ChangeType); - try - { - // Invalidate all function discovery cache entries - // Since we cache by lists of IDs, invalidating all is the safest approach - await _cacheService.InvalidateAllFunctionDiscoveryAsync(); - - _logger.LogInformation( - "Successfully invalidated function discovery cache for '{ConfigName}' (ID: {ConfigId})", - message.ConfigurationName, - message.FunctionConfigurationId); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Failed to invalidate function discovery cache for '{ConfigName}' (ID: {ConfigId})", - message.ConfigurationName, - message.FunctionConfigurationId); + protected override void LogSuccess(FunctionConfigurationChanged message) + => Logger.LogInformation( + "Successfully invalidated function discovery cache for '{ConfigName}' (ID: {ConfigId})", + message.ConfigurationName, + message.FunctionConfigurationId); - // Rethrow to allow MassTransit retry policy to handle the failure - throw; - } - } + protected override void LogFailure(FunctionConfigurationChanged message, Exception ex) + => Logger.LogError( + ex, + "Failed to invalidate function discovery cache for '{ConfigName}' (ID: {ConfigId})", + message.ConfigurationName, + message.FunctionConfigurationId); } /// /// Consumer that handles FunctionDiscoveryCacheInvalidationRequested events for manual cache invalidation /// triggered by admins via the Admin API. /// -public class FunctionDiscoveryCacheInvalidationRequestHandler : IConsumer +public class FunctionDiscoveryCacheInvalidationRequestHandler : CacheInvalidationConsumerBase { private readonly IFunctionDiscoveryCacheService _cacheService; - private readonly ILogger _logger; public FunctionDiscoveryCacheInvalidationRequestHandler( IFunctionDiscoveryCacheService cacheService, ILogger logger) + : base(logger) { _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task Consume(ConsumeContext context) - { - var message = context.Message; + protected override Task InvalidateCacheAsync(FunctionDiscoveryCacheInvalidationRequested message) + => _cacheService.InvalidateAllFunctionDiscoveryAsync(); - _logger.LogInformation( + protected override void LogReceived(FunctionDiscoveryCacheInvalidationRequested message) + => Logger.LogInformation( "Received FunctionDiscoveryCacheInvalidationRequested event. Reason: {Reason}, Requested by: {RequestedBy}", message.Reason, message.RequestedBy); - try - { - await _cacheService.InvalidateAllFunctionDiscoveryAsync(); + protected override void LogSuccess(FunctionDiscoveryCacheInvalidationRequested message) + => Logger.LogInformation( + "Successfully invalidated all function discovery cache entries. Reason: {Reason}", + message.Reason); - _logger.LogInformation( - "Successfully invalidated all function discovery cache entries. Reason: {Reason}", - message.Reason); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Failed to invalidate function discovery cache. Reason: {Reason}", - message.Reason); - - // Rethrow to allow MassTransit retry policy to handle the failure - throw; - } - } + protected override void LogFailure(FunctionDiscoveryCacheInvalidationRequested message, Exception ex) + => Logger.LogError( + ex, + "Failed to invalidate function discovery cache. Reason: {Reason}", + message.Reason); } diff --git a/Shared/ConduitLLM.Core/Consumers/GlobalSettingCacheInvalidationHandler.cs b/Shared/ConduitLLM.Core/Consumers/GlobalSettingCacheInvalidationHandler.cs index c5a04e603..21890dfff 100644 --- a/Shared/ConduitLLM.Core/Consumers/GlobalSettingCacheInvalidationHandler.cs +++ b/Shared/ConduitLLM.Core/Consumers/GlobalSettingCacheInvalidationHandler.cs @@ -1,6 +1,5 @@ using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Events; -using MassTransit; using Microsoft.Extensions.Logging; namespace ConduitLLM.Core.Consumers @@ -12,49 +11,38 @@ namespace ConduitLLM.Core.Consumers /// This ensures cache consistency when settings are modified via the Admin API. /// Both Gateway API and Admin API register this consumer to keep their caches synchronized. /// - public class GlobalSettingCacheInvalidationHandler : IConsumer + public class GlobalSettingCacheInvalidationHandler : CacheInvalidationConsumerBase { private readonly IGlobalSettingsCacheService _cacheService; - private readonly ILogger _logger; public GlobalSettingCacheInvalidationHandler( IGlobalSettingsCacheService cacheService, ILogger logger) + : base(logger) { _cacheService = cacheService; - _logger = logger; } - public async Task Consume(ConsumeContext context) - { - var message = context.Message; + protected override Task InvalidateCacheAsync(GlobalSettingChanged message) + => _cacheService.InvalidateSettingAsync(message.SettingKey); - _logger.LogInformation( + protected override void LogReceived(GlobalSettingChanged message) + => Logger.LogInformation( "Received GlobalSettingChanged event for setting '{SettingKey}' (ID: {SettingId}, ChangeType: {ChangeType})", message.SettingKey, message.SettingId, message.ChangeType); - try - { - // Invalidate the specific setting in the cache - await _cacheService.InvalidateSettingAsync(message.SettingKey); - - _logger.LogInformation( - "Successfully invalidated cache for setting '{SettingKey}'", - message.SettingKey); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Failed to invalidate cache for setting '{SettingKey}' (ID: {SettingId})", - message.SettingKey, - message.SettingId); + protected override void LogSuccess(GlobalSettingChanged message) + => Logger.LogInformation( + "Successfully invalidated cache for setting '{SettingKey}'", + message.SettingKey); - // Rethrow to allow MassTransit retry policy to handle the failure - throw; - } - } + protected override void LogFailure(GlobalSettingChanged message, Exception ex) + => Logger.LogError( + ex, + "Failed to invalidate cache for setting '{SettingKey}' (ID: {SettingId})", + message.SettingKey, + message.SettingId); } } diff --git a/Shared/ConduitLLM.Core/Controllers/EventPublishingControllerBase.cs b/Shared/ConduitLLM.Core/Controllers/EventPublishingControllerBase.cs index 3bf043a51..d5b0f5ba3 100644 --- a/Shared/ConduitLLM.Core/Controllers/EventPublishingControllerBase.cs +++ b/Shared/ConduitLLM.Core/Controllers/EventPublishingControllerBase.cs @@ -1,3 +1,9 @@ +using System.Diagnostics; + +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Extensions; +using ConduitLLM.Core.Metrics; + using MassTransit; using Microsoft.AspNetCore.Mvc; @@ -7,13 +13,19 @@ namespace ConduitLLM.Core.Controllers { /// /// Base class for controllers that publish domain events using MassTransit. - /// Provides fire-and-forget event publishing patterns with consistent error handling and logging. + /// Provides fire-and-forget event publishing patterns, shared utility methods, + /// and consistent error handling and logging. /// public abstract class EventPublishingControllerBase : ControllerBase { private readonly IPublishEndpoint? _publishEndpoint; private readonly ILogger _logger; + /// + /// Logger instance for derived controllers. + /// + protected ILogger Logger => _logger; + /// /// Initializes a new instance of the class. /// @@ -61,24 +73,30 @@ protected void PublishEventFireAndForget( _logger.LogWarning( "Event publishing not configured - skipping {EventType} for {Operation}", nameof(TEvent), operationName); + EventPublishingMetrics.RecordSkipped(typeof(TEvent).Name); return; } // Fire and forget - don't await _ = Task.Run(async () => { + var sw = Stopwatch.StartNew(); try { await _publishEndpoint.Publish(domainEvent); + sw.Stop(); _logger.LogDebug( "Published {EventType} event for {Operation}", nameof(TEvent), operationName); + EventPublishingMetrics.RecordSuccess(typeof(TEvent).Name, sw.Elapsed.TotalSeconds); } catch (Exception ex) { + sw.Stop(); _logger.LogWarning(ex, "Failed to publish {EventType} event for {Operation} - operation completed but event not sent", nameof(TEvent), operationName); + EventPublishingMetrics.RecordFailure(typeof(TEvent).Name); // Don't rethrow - event publishing should not fail business operations } }); @@ -110,24 +128,30 @@ protected void PublishEventFireAndForget( _logger.LogDebug( "Event publishing not configured - skipping {EventType} for {Operation} with context {ContextData}", nameof(TEvent), operationName, contextData); + EventPublishingMetrics.RecordSkipped(typeof(TEvent).Name); return; } // Fire and forget - don't await _ = Task.Run(async () => { + var sw = Stopwatch.StartNew(); try { await _publishEndpoint.Publish(domainEvent); + sw.Stop(); _logger.LogDebug( "Published {EventType} event for {Operation} with context {ContextData}", nameof(TEvent), operationName, contextData); + EventPublishingMetrics.RecordSuccess(typeof(TEvent).Name, sw.Elapsed.TotalSeconds); } catch (Exception ex) { + sw.Stop(); _logger.LogWarning(ex, "Failed to publish {EventType} event for {Operation} with context {ContextData} - operation completed but event not sent", nameof(TEvent), operationName, contextData); + EventPublishingMetrics.RecordFailure(typeof(TEvent).Name); // Don't rethrow - event publishing should not fail business operations } }); @@ -152,5 +176,80 @@ protected void LogEventPublishingConfiguration(string controllerName) controllerName); } } + + // โ”€โ”€โ”€ Shared Utility Methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// + /// Returns true if the current HTTP request is a mutation (POST, PUT, PATCH, DELETE). + /// + protected bool IsMutationRequest() + { + var method = HttpContext?.Request?.Method; + return method is "POST" or "PUT" or "PATCH" or "DELETE"; + } + + /// + /// Logs an exception with the request body for mutation requests (fire-and-forget). + /// Falls back to logging without body if capture fails. + /// Used by both Admin and Gateway controller bases for consistent error diagnostics. + /// + protected async Task LogExceptionWithBodyAsync( + ExceptionToResponseMapper.ExceptionMappingResult mapping, + Exception ex, + string logMessage) + { + string? requestBody = null; + try + { + requestBody = await RequestBodyCapture.CaptureAsync(HttpContext); + } + catch + { + // Body capture should never prevent error logging + } + + if (requestBody != null) + { + if (mapping.IncludeExceptionMessageInLog) + { + _logger.Log(mapping.LogLevel, ex, + "{LogPrefix} in {Operation}: {Message}. RequestBody: {RequestBody}", + mapping.LogPrefix, logMessage, ex.Message, requestBody); + } + else if (mapping.LogLevel == LogLevel.Error) + { + _logger.LogError(ex, + "{LogPrefix} in {Operation}. RequestBody: {RequestBody}", + mapping.LogPrefix, logMessage, requestBody); + } + else + { + _logger.LogWarning( + "{LogPrefix} in {Operation}. RequestBody: {RequestBody}", + mapping.LogPrefix, logMessage, requestBody); + } + } + else + { + if (mapping.IncludeExceptionMessageInLog) + { + _logger.Log(mapping.LogLevel, ex, + "{LogPrefix} in {Operation}: {Message}", + mapping.LogPrefix, logMessage, ex.Message); + } + else if (mapping.LogLevel == LogLevel.Error) + { + _logger.LogError(ex, + "{LogPrefix} in {Operation}", + mapping.LogPrefix, logMessage); + } + else + { + _logger.LogWarning( + "{LogPrefix} in {Operation}", + mapping.LogPrefix, logMessage); + } + } + } } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Controllers/GatewayControllerBase.cs b/Shared/ConduitLLM.Core/Controllers/GatewayControllerBase.cs new file mode 100644 index 000000000..83e031a3e --- /dev/null +++ b/Shared/ConduitLLM.Core/Controllers/GatewayControllerBase.cs @@ -0,0 +1,228 @@ +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Models; + +using MassTransit; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Controllers +{ + /// + /// Base class for Gateway API controllers providing standardized OpenAI-compatible + /// error handling and event publishing. + /// + /// + /// Returns for OpenAI API compatibility. + /// Uses for consistent exception-to-response mapping. + /// Shared utility methods (IsMutationRequest, LogExceptionWithBodyAsync) are in + /// . + /// + public abstract class GatewayControllerBase : EventPublishingControllerBase + { + /// + /// Initializes a new instance with event publishing support. + /// + protected GatewayControllerBase( + IPublishEndpoint? publishEndpoint, + ILogger logger) + : base(publishEndpoint, logger) + { + } + + /// + /// Initializes a new instance without event publishing. + /// + protected GatewayControllerBase(ILogger logger) + : this(null, logger) + { + } + + /// + /// Executes an async operation with standardized OpenAI-compatible error handling. + /// + protected async Task ExecuteAsync( + Func> operation, + Func successAction, + string operationName, + object? contextData = null) + { + try + { + var result = await operation(); + LogOperationSuccess(operationName, contextData); + return successAction(result); + } + catch (Exception ex) + { + return HandleOpenAIException(ex, operationName, contextData); + } + } + + /// + /// Executes an async operation that directly returns an IActionResult, + /// with standardized OpenAI-compatible error handling. + /// + protected async Task ExecuteAsync( + Func> operation, + string operationName, + object? contextData = null) + { + try + { + var result = await operation(); + LogOperationSuccess(operationName, contextData); + return result; + } + catch (Exception ex) + { + return HandleOpenAIException(ex, operationName, contextData); + } + } + + /// + /// Executes a void async operation with standardized OpenAI-compatible error handling. + /// + protected async Task ExecuteAsync( + Func operation, + IActionResult successResult, + string operationName, + object? contextData = null) + { + try + { + await operation(); + LogOperationSuccess(operationName, contextData); + return successResult; + } + catch (Exception ex) + { + return HandleOpenAIException(ex, operationName, contextData); + } + } + + /// + /// Logs operation success at Information level for mutations (POST/PUT/PATCH/DELETE) + /// and Debug level for reads (GET/HEAD/OPTIONS). + /// + private void LogOperationSuccess(string operationName, object? contextData = null) + { + if (IsMutationRequest()) + { + if (contextData != null) + { + Logger.LogInformation("{OperationName} completed successfully with context {ContextData}", + operationName, contextData); + } + else + { + Logger.LogInformation("{OperationName} completed successfully", operationName); + } + } + else + { + if (contextData != null) + { + Logger.LogDebug("{OperationName} completed successfully with context {ContextData}", + operationName, contextData); + } + else + { + Logger.LogDebug("{OperationName} completed successfully", operationName); + } + } + } + + /// + /// Authenticated virtual key ID for the current request, or null if unauthenticated. + /// Reads from HttpContext.Items["VirtualKeyId"] (populated by VirtualKeyAuthenticationHandler) + /// and falls back to the VirtualKeyId claim. + /// + protected int? CurrentVirtualKeyId + { + get + { + if (HttpContext.Items.TryGetValue("VirtualKeyId", out var idObj) && idObj is int id) + { + return id; + } + var claim = User.FindFirst("VirtualKeyId")?.Value; + if (!string.IsNullOrEmpty(claim) && int.TryParse(claim, out var parsed)) + { + return parsed; + } + return null; + } + } + + /// + /// Raw virtual key string for the current request, or null if unauthenticated. + /// Reads from HttpContext.Items["VirtualKey"] (populated by VirtualKeyAuthenticationHandler) + /// and falls back to the VirtualKey claim. + /// + protected string? CurrentVirtualKey + { + get + { + if (HttpContext.Items.TryGetValue("VirtualKey", out var keyObj) && keyObj is string key && !string.IsNullOrEmpty(key)) + { + return key; + } + return User.FindFirst("VirtualKey")?.Value; + } + } + + /// + /// Creates an OpenAI-compatible error response for explicit (non-exception) error returns. + /// Use this when returning validation errors or other expected failures from action methods. + /// + protected IActionResult OpenAIError( + int statusCode, + string message, + string code, + string type = "invalid_request_error") + { + return StatusCode(statusCode, new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = message, + Type = type, + Code = code + } + }); + } + + /// + /// Maps an exception to an OpenAI-compatible error response using . + /// Uses the mapper's LogPrefix and IncludeExceptionMessageInLog for structured, consistent error logging. + /// Captures request body for mutation failures (fire-and-forget) for post-mortem diagnostics. + /// + private IActionResult HandleOpenAIException( + Exception ex, + string operationName, + object? contextData = null) + { + var mapping = ExceptionToResponseMapper.Map(ex); + + var logMessage = contextData != null + ? $"{operationName} (context: {contextData})" + : operationName; + + // Capture request body for mutation failures (fire-and-forget โ€” don't block error response) + _ = LogExceptionWithBodyAsync(mapping, ex, logMessage); + + return StatusCode(mapping.StatusCode, new OpenAIErrorResponse + { + Error = new OpenAIError + { + Message = mapping.ResponseMessage, + Type = mapping.OpenAIErrorType, + Code = mapping.ErrorCode, + Param = mapping.Param + } + }); + } + + } +} diff --git a/Shared/ConduitLLM.Core/Decorators/ContextAwareLLMClient.cs b/Shared/ConduitLLM.Core/Decorators/ContextAwareLLMClient.cs index eeb84fadb..8f11db1e2 100644 --- a/Shared/ConduitLLM.Core/Decorators/ContextAwareLLMClient.cs +++ b/Shared/ConduitLLM.Core/Decorators/ContextAwareLLMClient.cs @@ -82,16 +82,10 @@ public async IAsyncEnumerable StreamChatCompletionAsync( } catch (Exception ex) { - // For debugging - write to console if logger is null - if (_logger == null) - { - Console.WriteLine($"[ContextAwareLLMClient] Logger is NULL! Exception: {ex.GetType().Name}"); - } - _logger?.LogWarning( "Caught exception in streaming: Type={ExceptionType}, Message={Message}, HasStatusCode={HasStatusCode}", ex.GetType().Name, ex.Message.Substring(0, Math.Min(ex.Message.Length, 200)), (ex as LLMCommunicationException)?.StatusCode); - + // Track error only once per stream if (!errorTracked) { @@ -108,9 +102,6 @@ public async IAsyncEnumerable StreamChatCompletionAsync( else { _logger?.LogWarning("Could not extract LLMCommunicationException from {ExceptionType}", ex.GetType().Name); - - // For debugging - Console.WriteLine($"[ContextAwareLLMClient] Failed to extract LLMCommunicationException from {ex.GetType().Name}"); } } throw; @@ -191,37 +182,34 @@ public async Task CreateVideoAsync( { try { - // Check if inner client supports video generation + // CreateVideoAsync is not on ILLMClient โ€” only specific providers implement it. + // Use reflection to invoke it on the concrete client type. var innerClientType = _innerClient.GetType(); var createVideoMethod = innerClientType.GetMethod("CreateVideoAsync", new[] { typeof(VideoGenerationRequest), typeof(string), typeof(CancellationToken) }); - + if (createVideoMethod == null) { - throw new NotSupportedException($"The underlying client {innerClientType.Name} does not support video generation"); - } - - // Invoke the method on the inner client - var task = createVideoMethod.Invoke(_innerClient, new object?[] { request, apiKey, cancellationToken }) as Task; - if (task != null) - { - return await task; + throw new NotSupportedException( + $"The underlying client {innerClientType.Name} does not support video generation"); } - else + + var task = (Task?)createVideoMethod.Invoke( + _innerClient, new object?[] { request, apiKey, cancellationToken }); + + if (task == null) { - throw new InvalidOperationException($"CreateVideoAsync method on {innerClientType.Name} did not return expected Task"); + throw new InvalidOperationException( + $"CreateVideoAsync on {innerClientType.Name} returned null"); } + + return await task; } catch (LLMCommunicationException ex) { await TrackErrorAsync(ex); throw; } - catch (Exception ex) when (!(ex is NotSupportedException || ex is InvalidOperationException)) - { - _logger?.LogError(ex, "Error in CreateVideoAsync"); - throw; - } } } diff --git a/Shared/ConduitLLM.Core/Decorators/PromptCachingLLMClient.cs b/Shared/ConduitLLM.Core/Decorators/PromptCachingLLMClient.cs new file mode 100644 index 000000000..13abfd2cc --- /dev/null +++ b/Shared/ConduitLLM.Core/Decorators/PromptCachingLLMClient.cs @@ -0,0 +1,105 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using ConduitLLM.Core.Metrics; +using ConduitLLM.Core.Services; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Decorators; + +/// +/// Decorator that automatically injects cache_control directives into chat completion +/// requests when prompt caching auto-injection is enabled via GlobalSettings. +/// +public class PromptCachingLLMClient : ILLMClient +{ + private readonly ILLMClient _innerClient; + private readonly IGlobalSettingsCacheService _settingsService; + private readonly ILogger _logger; + + /// + /// GlobalSettings key for the prompt caching configuration. + /// + public const string SettingsKey = "PromptCaching.Config"; + + public PromptCachingLLMClient( + ILLMClient innerClient, + IGlobalSettingsCacheService settingsService, + ILogger logger) + { + _innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient)); + _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task CreateChatCompletionAsync( + ChatCompletionRequest request, + string? apiKey = null, + CancellationToken cancellationToken = default) + { + await TryInjectCacheControlAsync(request); + return await _innerClient.CreateChatCompletionAsync(request, apiKey, cancellationToken); + } + + /// + public async IAsyncEnumerable StreamChatCompletionAsync( + ChatCompletionRequest request, + string? apiKey = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await TryInjectCacheControlAsync(request); + await foreach (var chunk in _innerClient.StreamChatCompletionAsync(request, apiKey, cancellationToken) + .WithCancellation(cancellationToken)) + { + yield return chunk; + } + } + + /// + public Task> ListModelsAsync(string? apiKey = null, CancellationToken cancellationToken = default) + => _innerClient.ListModelsAsync(apiKey, cancellationToken); + + /// + public Task CreateEmbeddingAsync(EmbeddingRequest request, string? apiKey = null, CancellationToken cancellationToken = default) + => _innerClient.CreateEmbeddingAsync(request, apiKey, cancellationToken); + + /// + public Task CreateImageAsync(ImageGenerationRequest request, string? apiKey = null, CancellationToken cancellationToken = default) + => _innerClient.CreateImageAsync(request, apiKey, cancellationToken); + + /// + public Task GetCapabilitiesAsync(string? modelId = null) + => _innerClient.GetCapabilitiesAsync(modelId); + + private async Task TryInjectCacheControlAsync(ChatCompletionRequest request) + { + try + { + var config = await GetPromptCachingConfigAsync(); + if (config is { AutoInjectEnabled: true }) + { + PromptCacheInjectionService.InjectCacheControl(request, config); + PromptCachingInjectionMetrics.RecordSuccess(request.Model ?? "unknown"); + _logger.LogDebug("Injected cache_control directives for model {Model}", request.Model); + } + } + catch (Exception ex) + { + // Don't fail the request if cache injection fails โ€” just log and continue + PromptCachingInjectionMetrics.RecordError(request.Model ?? "unknown"); + _logger.LogWarning(ex, "Failed to inject cache_control directives, continuing without caching"); + } + } + + private async Task GetPromptCachingConfigAsync() + { + var json = await _settingsService.GetSettingValueAsync(SettingsKey); + if (string.IsNullOrWhiteSpace(json)) + return null; + + return JsonSerializer.Deserialize(json); + } +} diff --git a/Shared/ConduitLLM.Core/Events/DomainEvents.ProviderTool.cs b/Shared/ConduitLLM.Core/Events/DomainEvents.ProviderTool.cs new file mode 100644 index 000000000..f1d6ca3e7 --- /dev/null +++ b/Shared/ConduitLLM.Core/Events/DomainEvents.ProviderTool.cs @@ -0,0 +1,38 @@ +namespace ConduitLLM.Core.Events +{ + // =============================== + // Provider Tool Domain Events + // =============================== + + /// + /// Raised when provider tools are created, updated, or deleted. + /// Critical for cache invalidation of tool cost lookups in the billing pipeline. + /// + public record ProviderToolChanged : DomainEvent + { + /// + /// Provider tool database ID + /// + public int ProviderToolId { get; init; } + + /// + /// Tool name that was affected + /// + public string ToolName { get; init; } = string.Empty; + + /// + /// Provider type string (e.g., "Groq", "OpenAI") + /// + public string ProviderType { get; init; } = string.Empty; + + /// + /// Type of change (Created, Updated, Deleted) + /// + public string ChangeType { get; init; } = string.Empty; + + /// + /// Partition key for ordered processing per provider type + /// + public string PartitionKey => ProviderType; + } +} diff --git a/Shared/ConduitLLM.Core/Exceptions/LiteLLMException.cs b/Shared/ConduitLLM.Core/Exceptions/ConduitException.cs similarity index 100% rename from Shared/ConduitLLM.Core/Exceptions/LiteLLMException.cs rename to Shared/ConduitLLM.Core/Exceptions/ConduitException.cs diff --git a/Shared/ConduitLLM.Core/Exceptions/ExceptionToResponseMapper.cs b/Shared/ConduitLLM.Core/Exceptions/ExceptionToResponseMapper.cs new file mode 100644 index 000000000..444ed1663 --- /dev/null +++ b/Shared/ConduitLLM.Core/Exceptions/ExceptionToResponseMapper.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Exceptions; + +/// +/// Maps exceptions to standardized HTTP response information. +/// Single source of truth for exception-to-response mapping across both +/// Admin API (ErrorResponseDto) and Gateway API (OpenAIErrorResponse). +/// +public static class ExceptionToResponseMapper +{ + /// + /// Contains the mapping result for an exception, including HTTP status code, + /// response message, error code, OpenAI error type, and logging information. + /// + /// The HTTP status code to return. + /// The message to include in the error response. For custom ConduitExceptions + /// this is the exception message; for standard .NET exceptions this is a safe generic message. + /// The programmatic error code for clients (e.g., "model_not_found", "rate_limit_exceeded"). + /// The log level to use when logging this exception. + /// The descriptive prefix for log messages (e.g., "Argument error"). + /// Whether the ResponseMessage contains the actual exception message. + /// When false, the ResponseMessage is a safe generic message and callers may choose to show the real + /// exception message in development environments. + /// The OpenAI-compatible error type string (e.g., "invalid_request_error", "server_error"). + /// The parameter that caused the error, if applicable (e.g., from ArgumentException.ParamName). + public record ExceptionMappingResult( + int StatusCode, + string ResponseMessage, + string ErrorCode, + LogLevel LogLevel, + string LogPrefix, + bool IncludeExceptionMessageInLog, + string OpenAIErrorType, + string? Param = null); + + /// + /// Maps an exception to its corresponding HTTP response information. + /// + /// The exception to map. + /// An containing response and logging information. + public static ExceptionMappingResult Map(Exception ex) + { + return ex switch + { + // Custom Conduit exceptions โ€” messages are user-safe + AuthorizationException authEx + => new(403, authEx.Message, "forbidden", LogLevel.Warning, + "Authorization denied", true, "invalid_request_error"), + + ModelNotFoundException modelEx + => new(404, modelEx.Message, "model_not_found", LogLevel.Warning, + "Model not found", true, "invalid_request_error", "model"), + + InvalidRequestException invalidReq + => new(400, invalidReq.Message, invalidReq.ErrorCode ?? "invalid_request", LogLevel.Warning, + "Invalid request", true, "invalid_request_error", invalidReq.Param), + + RequestTimeoutException timeoutEx + => new(408, timeoutEx.Message, "request_timeout", LogLevel.Warning, + "Request timeout", true, "timeout_error"), + + PayloadTooLargeException payloadEx + => new(413, payloadEx.Message, "payload_too_large", LogLevel.Warning, + "Payload too large", true, "invalid_request_error"), + + RateLimitExceededException rateEx + => new(429, rateEx.Message, "rate_limit_exceeded", LogLevel.Warning, + "Rate limit exceeded", true, "rate_limit_error"), + + ServiceUnavailableException serviceEx + => new(503, serviceEx.Message, "service_unavailable", LogLevel.Warning, + "Service unavailable", true, "service_unavailable"), + + LLMCommunicationException commEx + => MapLLMCommunicationException(commEx), + + ConfigurationException + => new(500, "A configuration error occurred", "configuration_error", LogLevel.Error, + "Configuration error", false, "server_error"), + + // Standard .NET exceptions โ€” use safe generic messages + ArgumentNullException argNullEx + => new(400, "Required parameter is missing", "missing_parameter", LogLevel.Warning, + "Argument error", false, "invalid_request_error", argNullEx.ParamName), + + ArgumentException argEx + => new(400, "Invalid parameter value", "invalid_parameter", LogLevel.Warning, + "Argument error", false, "invalid_request_error", argEx.ParamName), + + InvalidOperationException + => new(400, "The requested operation is not valid", "invalid_operation", LogLevel.Warning, + "Invalid operation", false, "invalid_request_error"), + + KeyNotFoundException + => new(404, "The requested resource was not found", "not_found", LogLevel.Warning, + "Resource not found", false, "invalid_request_error"), + + UnauthorizedAccessException + => new(401, "Authentication required", "unauthorized", LogLevel.Warning, + "Unauthorized access attempt", false, "invalid_request_error"), + + TimeoutException + => new(408, "Request timed out", "timeout", LogLevel.Warning, + "Request timeout", false, "timeout_error"), + + NotSupportedException + => new(400, "The requested feature is not supported", "not_supported", LogLevel.Warning, + "Not supported", false, "invalid_request_error"), + + NotImplementedException + => new(501, "Feature not implemented", "not_implemented", LogLevel.Warning, + "Not implemented", false, "server_error"), + + // Catch-all for unexpected exceptions + _ => new(500, "An unexpected error occurred", "internal_error", LogLevel.Error, + "Unexpected error", false, "server_error") + }; + } + + /// + /// Maps an LLMCommunicationException, deriving status code and error type from the provider's response. + /// + private static ExceptionMappingResult MapLLMCommunicationException(LLMCommunicationException commEx) + { + if (commEx.StatusCode.HasValue) + { + var statusCode = (int)commEx.StatusCode.Value; + var isServerError = statusCode >= 500; + return new( + statusCode, + commEx.Message, + "provider_communication_error", + isServerError ? LogLevel.Error : LogLevel.Warning, + "Provider communication error", + true, + isServerError ? "server_error" : "invalid_request_error"); + } + + return new(500, commEx.Message, "provider_communication_error", LogLevel.Error, + "Provider communication error", true, "server_error"); + } +} diff --git a/Shared/ConduitLLM.Core/Extensions/CacheManagerExtensions.cs b/Shared/ConduitLLM.Core/Extensions/CacheManagerExtensions.cs index 53a6e5b7e..91a1fc6e6 100644 --- a/Shared/ConduitLLM.Core/Extensions/CacheManagerExtensions.cs +++ b/Shared/ConduitLLM.Core/Extensions/CacheManagerExtensions.cs @@ -18,6 +18,7 @@ public static class CacheManagerExtensions { /// /// Adds the unified cache manager to the service collection. + /// Automatically detects Redis configuration and uses distributed statistics if available. /// /// The service collection. /// The configuration. @@ -31,13 +32,64 @@ public static IServiceCollection AddCacheManager(this IServiceCollection service services.Configure(configuration.GetSection("CacheManager")); services.Configure(configuration.GetSection("CacheStatistics")); - // Register statistics collector (local mode only) + // Check if Redis is configured for distributed statistics + var redisConnection = configuration.GetConnectionString("Redis") ?? configuration["Redis:Configuration"]; + if (!string.IsNullOrEmpty(redisConnection)) + { + // Add Redis distributed cache + services.AddStackExchangeRedisCache(options => + { + options.Configuration = redisConnection; + options.InstanceName = "conduit:cache:"; + }); + + // Register statistics store for Redis + services.TryAddSingleton(); + + // Register Redis connection multiplexer with lazy initialization + services.TryAddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + logger.LogInformation("Creating Redis connection for cache statistics"); + + var configOptions = ConfigurationOptions.Parse(redisConnection); + configOptions.AbortOnConnectFail = false; + configOptions.ConnectTimeout = 5000; + configOptions.ConnectRetry = 3; + + try + { + return ConnectionMultiplexer.Connect(configOptions); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create Redis connection. Cache statistics will use in-memory storage."); + throw; + } + }); + + // Register distributed statistics collector + services.TryAddSingleton(); + } + + // Register statistics collector (hybrid if Redis is available, local otherwise) services.AddSingleton(sp => { - return new CacheStatisticsCollector( + var distributedCollector = sp.GetService(); + var localCollector = new CacheStatisticsCollector( sp.GetRequiredService>(), sp.GetRequiredService>(), sp.GetService()); + + if (distributedCollector != null) + { + return new HybridCacheStatisticsCollector( + localCollector, + distributedCollector, + sp.GetRequiredService>()); + } + + return localCollector; }); // Register policy engine @@ -53,6 +105,8 @@ public static IServiceCollection AddCacheManager(this IServiceCollection service /// /// Adds the unified cache manager with custom options. + /// Note: This overload does not support distributed statistics as it lacks configuration access. + /// Use for Redis-backed statistics. /// /// The service collection. /// Action to configure options. diff --git a/Shared/ConduitLLM.Core/Extensions/LoggingSanitizer.cs b/Shared/ConduitLLM.Core/Extensions/LoggingSanitizer.cs index 4494284c0..072bbdd9b 100644 --- a/Shared/ConduitLLM.Core/Extensions/LoggingSanitizer.cs +++ b/Shared/ConduitLLM.Core/Extensions/LoggingSanitizer.cs @@ -1,50 +1,26 @@ using System.Runtime.CompilerServices; -using System.Text.RegularExpressions; -using ConduitLLM.Core.Utilities; +using ConfigLoggingSanitizer = ConduitLLM.Configuration.Utilities.LoggingSanitizer; namespace ConduitLLM.Core.Extensions { /// /// Provides methods to sanitize values for logging to prevent log injection attacks. - /// This class uses patterns that static analysis tools like CodeQL can recognize. + /// This class delegates to the canonical implementation in ConduitLLM.Configuration.Utilities. /// + /// + /// This is a facade for backward compatibility. The canonical implementation is in + /// . + /// New code should use the Configuration namespace directly. + /// public static class LoggingSanitizer { - private static readonly Regex CrlfPattern = new(@"[\r\n]", RegexOptions.Compiled); - private static readonly Regex ControlCharPattern = new(@"[\x00-\x1F\x7F]", RegexOptions.Compiled); - private static readonly Regex UnicodeSeparatorPattern = new(@"[\u2028\u2029]", RegexOptions.Compiled); - private const int MaxLength = 1000; - /// - /// Sanitizes a value for safe logging. This method is designed to be recognized by static analysis tools. + /// Sanitizes a value for safe logging. /// /// The value to sanitize. /// The sanitized value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static object? S(object? value) - { - if (value == null) return null; - - var str = value.ToString(); - if (str == null) return value; - - // Remove CRLF to prevent log injection - str = CrlfPattern.Replace(str, " "); - - // Remove control characters - str = ControlCharPattern.Replace(str, string.Empty); - - // Remove Unicode line/paragraph separators - str = UnicodeSeparatorPattern.Replace(str, " "); - - // Truncate if too long (no ellipsis - exact length for security logging) - if (str.Length > MaxLength) - { - str = new string(str.AsSpan(0, MaxLength)); - } - - return str; - } + public static object? S(object? value) => ConfigLoggingSanitizer.S(value); /// /// Sanitizes a string value for safe logging. @@ -52,27 +28,7 @@ public static class LoggingSanitizer /// The string to sanitize. /// The sanitized string. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string? S(string? value) - { - if (string.IsNullOrEmpty(value)) return value; - - // Remove CRLF to prevent log injection - value = CrlfPattern.Replace(value, " "); - - // Remove control characters - value = ControlCharPattern.Replace(value, string.Empty); - - // Remove Unicode line/paragraph separators - value = UnicodeSeparatorPattern.Replace(value, " "); - - // Truncate if too long (no ellipsis - exact length for security logging) - if (value.Length > MaxLength) - { - value = new string(value.AsSpan(0, MaxLength)); - } - - return value; - } + public static string? S(string? value) => ConfigLoggingSanitizer.S(value); /// /// Sanitizes an integer value (pass-through for type safety). @@ -110,4 +66,4 @@ public static class LoggingSanitizer [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Guid S(Guid value) => value; } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Core/Extensions/RedisExtensions.cs b/Shared/ConduitLLM.Core/Extensions/RedisExtensions.cs new file mode 100644 index 000000000..e9f09af3d --- /dev/null +++ b/Shared/ConduitLLM.Core/Extensions/RedisExtensions.cs @@ -0,0 +1,21 @@ +using StackExchange.Redis; + +namespace ConduitLLM.Core.Extensions; + +/// +/// Extension methods for StackExchange.Redis types. +/// +public static class RedisExtensions +{ + /// + /// Gets the primary Redis server from a multiplexer connection. + /// Provides defensive checking against empty endpoint lists. + /// + public static IServer GetPrimaryServer(this IConnectionMultiplexer multiplexer) + { + var endpoints = multiplexer.GetEndPoints(); + if (endpoints.Length == 0) + throw new InvalidOperationException("No Redis endpoints available."); + return multiplexer.GetServer(endpoints[0]); + } +} diff --git a/Shared/ConduitLLM.Core/Extensions/RequestBodyCapture.cs b/Shared/ConduitLLM.Core/Extensions/RequestBodyCapture.cs new file mode 100644 index 000000000..d9ef870f9 --- /dev/null +++ b/Shared/ConduitLLM.Core/Extensions/RequestBodyCapture.cs @@ -0,0 +1,99 @@ +using System.Text.RegularExpressions; + +using Microsoft.AspNetCore.Http; + +namespace ConduitLLM.Core.Extensions; + +/// +/// Provides methods for capturing and sanitizing HTTP request bodies for error diagnostics. +/// Used by controller base classes and exception middleware to log request payloads on failure. +/// +public static partial class RequestBodyCapture +{ + /// + /// Maximum number of characters to capture from the request body. + /// + private const int MaxBodyLength = 4096; + + /// + /// HTTP methods that typically carry a request body worth capturing. + /// + private static readonly HashSet MutationMethods = new(StringComparer.OrdinalIgnoreCase) + { + "POST", "PUT", "PATCH", "DELETE" + }; + + /// + /// Reads and sanitizes the request body from the current HTTP context. + /// Returns null for GET/HEAD requests or when the body is empty/unreadable. + /// + /// The HTTP context. + /// The sanitized request body, or null if not applicable. + public static async Task CaptureAsync(HttpContext? context) + { + if (context == null) + return null; + + // Only capture body for mutation methods + if (!MutationMethods.Contains(context.Request.Method)) + return null; + + // Content-Length check โ€” skip if body is clearly empty + if (context.Request.ContentLength is 0) + return null; + + try + { + // Ensure the body can be re-read (requires EnableBuffering called earlier) + context.Request.Body.Position = 0; + + using var reader = new StreamReader( + context.Request.Body, + leaveOpen: true); + + var body = await reader.ReadToEndAsync(); + + // Reset position for any downstream consumers + context.Request.Body.Position = 0; + + if (string.IsNullOrWhiteSpace(body)) + return null; + + // Redact sensitive fields first, then truncate + var redacted = RedactSensitiveFields(body); + + if (redacted.Length > MaxBodyLength) + { + redacted = redacted[..MaxBodyLength] + "...[truncated]"; + } + + // LoggingSanitizer.S() strips control characters and enforces its own + // max length (1000 chars), providing a final safety net + return LoggingSanitizer.S(redacted); + } + catch + { + // Body read failures should never impact error handling + return null; + } + } + + /// + /// Redacts values of JSON fields whose names suggest sensitive content. + /// Works on raw strings โ€” does not require valid JSON. + /// + private static string RedactSensitiveFields(string body) + { + return SensitiveFieldPattern().Replace(body, "$1\"[REDACTED]\""); + } + + /// + /// Matches JSON key-value pairs where the key contains a sensitive keyword. + /// Captures: "apiKey": "some-value" โ†’ "apiKey": "[REDACTED]" + /// Group 1 captures everything up to and including the colon+whitespace before the value. + /// + [GeneratedRegex( + """("(?:[^"]*(?:key|secret|password|token|credential|auth|apikey|api_key)[^"]*)":\s*)"(?:[^"\\]|\\.)*""", + RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex SensitiveFieldPattern(); +} diff --git a/Shared/ConduitLLM.Core/Extensions/ServiceCollectionExtensions.cs b/Shared/ConduitLLM.Core/Extensions/ServiceCollectionExtensions.cs index f4b70b933..37702bbed 100644 --- a/Shared/ConduitLLM.Core/Extensions/ServiceCollectionExtensions.cs +++ b/Shared/ConduitLLM.Core/Extensions/ServiceCollectionExtensions.cs @@ -1,14 +1,18 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Repositories; +using ConduitLLM.Configuration.Services; using ConduitLLM.Core.Configuration; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Options; +using ConduitLLM.Core.Policies; using ConduitLLM.Core.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; -using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Core.Extensions { /// @@ -34,8 +38,13 @@ public static IServiceCollection AddConduitContextManagement(this IServiceCollec // Register token counter - changed to Scoped to match IModelCapabilityService lifetime services.AddScoped(); - // Register image token calculator for accurate vision model billing - services.AddScoped(); + // Register image token calculator with retry-enabled HttpClient for accurate vision model billing + services.AddHttpClient() + .AddPolicyHandler(HttpRetryPolicies.GetStandardRetryPolicy()) + .ConfigureHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(30); // Reasonable timeout for image dimension checks + }); // Register usage estimation service for streaming responses without usage data services.AddScoped(); @@ -57,9 +66,6 @@ public static IServiceCollection AddModelCapabilityServices(this IServiceCollect // Register model capability service if not already registered - use database-backed implementation services.TryAddScoped(); - // Register capability detector if not already registered - services.TryAddScoped(); - // Register performance optimization services services.AddMemoryCache(); @@ -149,10 +155,7 @@ public static IServiceCollection AddMediaServices(this IServiceCollection servic var directEnvVar = Environment.GetEnvironmentVariable("CONDUIT_MEDIA_STORAGE_TYPE"); var storageProvider = configProvider ?? configEnvVar ?? directEnvVar ?? "InMemory"; - - // Log the selected storage provider for debugging (will be logged when first service is resolved) - Console.WriteLine($"[MediaServices] Storage Provider Selected: {storageProvider}"); - + // Configure media storage based on provider if (storageProvider.Equals("S3", StringComparison.OrdinalIgnoreCase)) { @@ -161,53 +164,21 @@ public static IServiceCollection AddMediaServices(this IServiceCollection servic { // First try to bind from the configuration section configuration.GetSection(S3StorageOptions.SectionName).Bind(options); - + // Then override with environment variables if they exist - var endpoint = configuration["CONDUIT_S3_ENDPOINT"] ?? Environment.GetEnvironmentVariable("CONDUIT_S3_ENDPOINT"); - if (!string.IsNullOrEmpty(endpoint)) - { - options.ServiceUrl = endpoint; - } - - var accessKey = configuration["CONDUIT_S3_ACCESS_KEY_ID"] - ?? configuration["CONDUIT_S3_ACCESS_KEY"] - ?? Environment.GetEnvironmentVariable("CONDUIT_S3_ACCESS_KEY_ID") - ?? Environment.GetEnvironmentVariable("CONDUIT_S3_ACCESS_KEY"); - if (!string.IsNullOrEmpty(accessKey)) - { - options.AccessKey = accessKey; - } - - var secretKey = configuration["CONDUIT_S3_SECRET_ACCESS_KEY"] - ?? configuration["CONDUIT_S3_SECRET_KEY"] - ?? Environment.GetEnvironmentVariable("CONDUIT_S3_SECRET_ACCESS_KEY") - ?? Environment.GetEnvironmentVariable("CONDUIT_S3_SECRET_KEY"); - if (!string.IsNullOrEmpty(secretKey)) - { - options.SecretKey = secretKey; - } - - var bucketName = configuration["CONDUIT_S3_BUCKET_NAME"] - ?? Environment.GetEnvironmentVariable("CONDUIT_S3_BUCKET_NAME"); - if (!string.IsNullOrEmpty(bucketName)) - { - options.BucketName = bucketName; - } - - var region = configuration["CONDUIT_S3_REGION"] - ?? Environment.GetEnvironmentVariable("CONDUIT_S3_REGION"); - if (!string.IsNullOrEmpty(region)) - { - options.Region = region; - } - - var publicBaseUrl = configuration["CONDUIT_S3_PUBLIC_BASE_URL"] - ?? Environment.GetEnvironmentVariable("CONDUIT_S3_PUBLIC_BASE_URL"); - if (!string.IsNullOrEmpty(publicBaseUrl)) - { - options.PublicBaseUrl = publicBaseUrl; - } - + ApplyConfigOrEnvVar(configuration, value => options.ServiceUrl = value, + "CONDUIT_S3_ENDPOINT"); + ApplyConfigOrEnvVar(configuration, value => options.AccessKey = value, + "CONDUIT_S3_ACCESS_KEY_ID", "CONDUIT_S3_ACCESS_KEY"); + ApplyConfigOrEnvVar(configuration, value => options.SecretKey = value, + "CONDUIT_S3_SECRET_ACCESS_KEY", "CONDUIT_S3_SECRET_KEY"); + ApplyConfigOrEnvVar(configuration, value => options.BucketName = value, + "CONDUIT_S3_BUCKET_NAME"); + ApplyConfigOrEnvVar(configuration, value => options.Region = value, + "CONDUIT_S3_REGION"); + ApplyConfigOrEnvVar(configuration, value => options.PublicBaseUrl = value, + "CONDUIT_S3_PUBLIC_BASE_URL"); + // Set defaults for S3 compatibility options.ForcePathStyle = true; options.AutoCreateBucket = true; @@ -228,12 +199,60 @@ public static IServiceCollection AddMediaServices(this IServiceCollection servic // Register media lifecycle service services.AddScoped(); - + // Register media lifecycle repository // MediaLifecycleRepository removed - consolidated into MediaRecordRepository // Migration: 20250827194408_ConsolidateMediaTables.cs - + return services; } + + /// + /// Registers application services shared by both Admin API and Gateway API. + /// Centralizes registrations that were previously duplicated across both services. + /// + public static IServiceCollection AddSharedApplicationServices(this IServiceCollection services) + { + // Global settings cache โ€” loads settings at startup and provides fast access + services.AddSingleton(); + services.AddHostedService(provider => + provider.GetRequiredService() as GlobalSettingsCacheService + ?? throw new InvalidOperationException("GlobalSettingsCacheService must be registered as singleton")); + + // Provider service + services.AddScoped(); + + // Model provider mapping with caching decorator + services.AddScoped(); + services.AddScoped(provider => + { + var innerService = provider.GetRequiredService(); + var cacheManager = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + return new CachedModelProviderMappingService(innerService, cacheManager, logger); + }); + + // Provider metadata registry โ€” single source of truth for provider metadata + services.AddSingleton(); + + return services; + } + + /// + /// Resolves a configuration value by checking IConfiguration keys and environment variables in order. + /// If a non-empty value is found, applies it via the setter. + /// + private static void ApplyConfigOrEnvVar(IConfiguration configuration, Action setter, params string[] keys) + { + foreach (var key in keys) + { + var value = configuration[key] ?? Environment.GetEnvironmentVariable(key); + if (!string.IsNullOrEmpty(value)) + { + setter(value); + return; + } + } + } } } diff --git a/Shared/ConduitLLM.Core/Extensions/SharedHttpClientExtensions.cs b/Shared/ConduitLLM.Core/Extensions/SharedHttpClientExtensions.cs new file mode 100644 index 000000000..50c3e7c40 --- /dev/null +++ b/Shared/ConduitLLM.Core/Extensions/SharedHttpClientExtensions.cs @@ -0,0 +1,73 @@ +using ConduitLLM.Core.Policies; +using ConduitLLM.Core.Services; + +using Microsoft.Extensions.DependencyInjection; + +namespace ConduitLLM.Core.Extensions; + +/// +/// Shared HTTP client registrations used by both Gateway and Admin services. +/// Centralizes configuration to avoid duplication and ensure consistent behavior. +/// +public static class SharedHttpClientExtensions +{ + /// + /// Registers HTTP clients shared between Gateway and Admin: DiscoveryProviders, + /// ImageDownload (ExternalImageFetch), Exa, and Tavily function providers. + /// Also registers for DI-friendly image downloading. + /// + public static IServiceCollection AddSharedHttpClients(this IServiceCollection services) + { + // Configure HttpClient for discovery providers + services.AddHttpClient("DiscoveryProviders", client => + { + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM/1.0"); + }); + + // Register HTTP client for external image fetching (used by IImageDownloadService) + services.AddHttpClient(ImageDownloadService.HttpClientName, client => + { + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Add("User-Agent", "Conduit-LLM/1.0"); + client.DefaultRequestHeaders.Add("Accept", "image/*"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + MaxConnectionsPerServer = 20, + EnableMultipleHttp2Connections = true + }); + + // Register IImageDownloadService for DI-friendly image downloading + services.AddScoped(); + + // Register HTTP clients for function providers (Exa and Tavily) + AddFunctionProviderHttpClient(services, "ExaFunctionClient"); + AddFunctionProviderHttpClient(services, "TavilyFunctionClient"); + + return services; + } + + /// + /// Registers an HTTP client for a function provider with standard configuration. + /// + private static void AddFunctionProviderHttpClient(IServiceCollection services, string clientName) + { + services.AddHttpClient(clientName, client => + { + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Add("User-Agent", "ConduitLLM-Functions"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + MaxConnectionsPerServer = 10, + EnableMultipleHttp2Connections = true + }) + .AddPolicyHandler(HttpRetryPolicies.GetStandardRetryPolicy()); + } +} diff --git a/Shared/ConduitLLM.Core/Extensions/SignalRConfigurationExtensions.cs b/Shared/ConduitLLM.Core/Extensions/SignalRConfigurationExtensions.cs new file mode 100644 index 000000000..b0a44eef7 --- /dev/null +++ b/Shared/ConduitLLM.Core/Extensions/SignalRConfigurationExtensions.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ConduitLLM.Core.Extensions +{ + /// + /// Shared SignalR configuration used by both Gateway and Admin APIs. + /// + public static class SignalRConfigurationExtensions + { + /// + /// Configures SignalR with standard hub options, optional MessagePack protocol, + /// and optional Redis backplane. Both Gateway and Admin use identical settings + /// except for the Redis channel prefix and database number. + /// + /// The service collection. + /// The host environment. + /// Redis connection string (null to skip backplane). + /// Channel prefix for this service's SignalR Redis backplane. + /// Redis database number for this service's SignalR backplane. + /// Display name for console logging (e.g., "Conduit", "ConduitLLM.Admin"). + /// Optional callback to add service-specific hub options (e.g., filters). + /// The configured SignalR server builder for further customization. + public static ISignalRServerBuilder AddConduitSignalR( + this IServiceCollection services, + IHostEnvironment environment, + string? redisConnectionString, + string redisChannelPrefix, + int redisDatabase, + string serviceName, + Action? configureHubOptions = null) + { + var signalRBuilder = services.AddSignalR(options => + { + options.EnableDetailedErrors = environment.IsDevelopment(); + options.ClientTimeoutInterval = TimeSpan.FromSeconds(60); + options.KeepAliveInterval = TimeSpan.FromSeconds(30); + options.MaximumReceiveMessageSize = 32 * 1024; // 32KB + options.StreamBufferCapacity = 10; + + configureHubOptions?.Invoke(options); + }); + + // Add MessagePack protocol support with LZ4 compression + var messagePackEnabled = Environment.GetEnvironmentVariable("SIGNALR_MESSAGEPACK_ENABLED")?.ToLowerInvariant() != "false"; + if (messagePackEnabled) + { + signalRBuilder.AddMessagePackProtocol(options => + { + options.SerializerOptions = MessagePack.MessagePackSerializerOptions.Standard + .WithResolver(MessagePack.Resolvers.ContractlessStandardResolver.Instance) + .WithSecurity(MessagePack.MessagePackSecurity.UntrustedData) + .WithCompression(MessagePack.MessagePackCompression.Lz4BlockArray) + .WithCompressionMinLength(256); + }); + } + + // Configure SignalR Redis backplane for horizontal scaling + if (!string.IsNullOrEmpty(redisConnectionString)) + { + signalRBuilder.AddStackExchangeRedis(redisConnectionString, options => + { + options.Configuration.ChannelPrefix = new StackExchange.Redis.RedisChannel(redisChannelPrefix, StackExchange.Redis.RedisChannel.PatternMode.Literal); + options.Configuration.DefaultDatabase = redisDatabase; + }); + } + + return signalRBuilder; + } + } +} diff --git a/Shared/ConduitLLM.Core/Interfaces/IBatchOperationNotificationService.cs b/Shared/ConduitLLM.Core/Interfaces/IBatchOperationNotificationService.cs index 53a37fda9..6af206158 100644 --- a/Shared/ConduitLLM.Core/Interfaces/IBatchOperationNotificationService.cs +++ b/Shared/ConduitLLM.Core/Interfaces/IBatchOperationNotificationService.cs @@ -31,18 +31,6 @@ Task NotifyBatchOperationProgressAsync( string? currentItem = null, string? message = null); - /// - /// Notifies that a single item in the batch has been completed - /// - Task NotifyBatchItemCompletedAsync( - string operationId, - int itemIndex, - string? itemIdentifier, - bool success, - string? error, - TimeSpan duration, - object? result = null); - /// /// Notifies that a batch operation has completed /// diff --git a/Shared/ConduitLLM.Core/Interfaces/ICachedPricingRulesService.cs b/Shared/ConduitLLM.Core/Interfaces/ICachedPricingRulesService.cs index d5b55b06b..a5e8096ab 100644 --- a/Shared/ConduitLLM.Core/Interfaces/ICachedPricingRulesService.cs +++ b/Shared/ConduitLLM.Core/Interfaces/ICachedPricingRulesService.cs @@ -22,10 +22,12 @@ public interface ICachedPricingRulesService /// Should be called when the model cost's pricing configuration is updated. /// /// The ID of the model cost entity. - void InvalidateCache(int modelCostId); + /// Cancellation token. + Task InvalidateCacheAsync(int modelCostId, CancellationToken cancellationToken = default); /// /// Invalidates all cached pricing rules configurations. /// - void InvalidateAll(); + /// Cancellation token. + Task InvalidateAllAsync(CancellationToken cancellationToken = default); } diff --git a/Shared/ConduitLLM.Core/Interfaces/IConduit.cs b/Shared/ConduitLLM.Core/Interfaces/IConduit.cs index 862a8461f..5ad0ff142 100644 --- a/Shared/ConduitLLM.Core/Interfaces/IConduit.cs +++ b/Shared/ConduitLLM.Core/Interfaces/IConduit.cs @@ -35,7 +35,7 @@ IAsyncEnumerable StreamChatCompletionAsync( ChatCompletionRequest request, string? apiKey = null, int? virtualKeyId = null, - Func? onToolExecutingEvent = null, + Func? onToolExecutingEvent = null, CancellationToken cancellationToken = default); /// @@ -63,10 +63,11 @@ Task CreateImageAsync( CancellationToken cancellationToken = default); /// - /// Gets an LLM client for the specified model. + /// Asynchronously gets an LLM client for the specified model. /// /// The model alias to get a client for. + /// A token to cancel the operation. /// The LLM client for the specified model. - ILLMClient GetClient(string modelAlias); + Task GetClientAsync(string modelAlias, CancellationToken cancellationToken = default); } } diff --git a/Shared/ConduitLLM.Core/Interfaces/ICostCalculationService.cs b/Shared/ConduitLLM.Core/Interfaces/ICostCalculationService.cs index 2322d98fa..2d8070df8 100644 --- a/Shared/ConduitLLM.Core/Interfaces/ICostCalculationService.cs +++ b/Shared/ConduitLLM.Core/Interfaces/ICostCalculationService.cs @@ -26,6 +26,26 @@ public interface ICostCalculationService /// The calculated cost as a decimal, or 0 if cost cannot be determined. Task CalculateCostByIdAsync(int modelCostId, Usage usage, CancellationToken cancellationToken = default); + /// + /// Calculates the estimated cost savings from prompt caching for a request. + /// Savings = cached_input_tokens * (standard_input_rate - cached_input_rate) / 1,000,000. + /// Returns 0 if no cached tokens or no cached pricing configured. + /// + /// The specific model ID used. + /// The usage data returned by the provider. + /// Cancellation token. + /// The estimated savings in dollars, or 0 if not applicable. + Task CalculateCacheSavingsAsync(string modelId, Usage usage, CancellationToken cancellationToken = default); + + /// + /// Calculates the estimated cost savings from prompt caching using a direct ModelCost ID lookup. + /// + /// The ID of the ModelCost record to use for pricing. + /// The usage data returned by the provider. + /// Cancellation token. + /// The estimated savings in dollars, or 0 if not applicable. + Task CalculateCacheSavingsByIdAsync(int modelCostId, Usage usage, CancellationToken cancellationToken = default); + /// /// Calculates a refund for a previous LLM operation. /// diff --git a/Shared/ConduitLLM.Core/Interfaces/IDiscoveryCacheService.cs b/Shared/ConduitLLM.Core/Interfaces/IDiscoveryCacheService.cs index f71ba0120..61c17d27f 100644 --- a/Shared/ConduitLLM.Core/Interfaces/IDiscoveryCacheService.cs +++ b/Shared/ConduitLLM.Core/Interfaces/IDiscoveryCacheService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -58,9 +59,11 @@ public interface IDiscoveryCacheService public class DiscoveryModelsResult { /// - /// List of discovered models + /// List of discovered models serialized as JsonElement for reliable cache round-tripping. + /// Anonymous objects cannot survive JSON deserialization, so we store them as JsonElement + /// which serializes/deserializes correctly and produces the same JSON output for API consumers. /// - public List Data { get; set; } = new(); + public List Data { get; set; } = new(); /// /// Total count of models diff --git a/Shared/ConduitLLM.Core/Interfaces/IDistributedCachePopulator.cs b/Shared/ConduitLLM.Core/Interfaces/IDistributedCachePopulator.cs new file mode 100644 index 000000000..79a842990 --- /dev/null +++ b/Shared/ConduitLLM.Core/Interfaces/IDistributedCachePopulator.cs @@ -0,0 +1,37 @@ +namespace ConduitLLM.Core.Interfaces +{ + /// + /// Provides cache stampede prevention for distributed cache operations. + /// When cache entries expire, this service ensures only one instance performs the + /// expensive database fallback while other concurrent requests wait. + /// + public interface IDistributedCachePopulator + { + /// + /// Gets a value from cache, or populates it using the factory function with stampede prevention. + /// Uses hybrid locking (local + distributed) to prevent multiple instances from simultaneously + /// hitting the database when a cache entry expires. + /// + /// The type of the cached value. + /// A unique key for the distributed lock (e.g., "populate:modelcost:pattern:gpt-4"). + /// A function that checks if the value exists in cache and returns it. + /// A function that fetches the value from the database when cache is empty. + /// Cancellation token. + /// The cached or freshly populated value, or null if not found. + /// + /// The locking strategy is: + /// 1. Check cache (fast path, no lock) + /// 2. Acquire local SemaphoreSlim (per-key) to prevent same-instance stampede + /// 3. Double-check cache after local lock + /// 4. Acquire distributed lock to prevent cross-instance stampede + /// 5. Triple-check cache after distributed lock + /// 6. Call factory (database fallback) + /// 7. Release locks in reverse order + /// + Task GetOrPopulateAsync( + string lockKey, + Func> cacheCheck, + Func> factory, + CancellationToken cancellationToken = default) where T : class; + } +} diff --git a/Shared/ConduitLLM.Core/Interfaces/IFileRetrievalService.cs b/Shared/ConduitLLM.Core/Interfaces/IFileRetrievalService.cs index 1508eefc1..96382f02c 100644 --- a/Shared/ConduitLLM.Core/Interfaces/IFileRetrievalService.cs +++ b/Shared/ConduitLLM.Core/Interfaces/IFileRetrievalService.cs @@ -63,12 +63,17 @@ public class FileRetrievalResult : IDisposable /// public required FileMetadata Metadata { get; set; } + // Held alongside ContentStream when the stream's lifetime is tied to a parent + // resource (e.g. HttpResponseMessage). Disposed together with the stream. + internal IDisposable? Owner { get; set; } + /// - /// Disposes the content stream. + /// Disposes the content stream and any owning resource. /// public void Dispose() { ContentStream?.Dispose(); + Owner?.Dispose(); } } diff --git a/Shared/ConduitLLM.Core/Interfaces/IImageDownloadService.cs b/Shared/ConduitLLM.Core/Interfaces/IImageDownloadService.cs new file mode 100644 index 000000000..f1a407805 --- /dev/null +++ b/Shared/ConduitLLM.Core/Interfaces/IImageDownloadService.cs @@ -0,0 +1,31 @@ +using ConduitLLM.Core.Models; + +namespace ConduitLLM.Core.Interfaces; + +/// +/// Service for downloading images from external URLs using properly managed HTTP connections. +/// This service uses IHttpClientFactory to avoid socket exhaustion under high load. +/// +public interface IImageDownloadService +{ + /// + /// Downloads an image from the specified URL. + /// + /// The URL of the image to download. + /// A token to monitor for cancellation requests. + /// The image data as a byte array. + /// Thrown when the URL is null or empty. + /// Thrown when the image download fails. + Task DownloadImageAsync(string url, CancellationToken cancellationToken = default); + + /// + /// Downloads an image from the specified URL and converts it to an ImageUrl with base64 data URL. + /// + /// The URL of the image to download. + /// Optional detail level for vision models (e.g., "low", "high", "auto"). + /// A token to monitor for cancellation requests. + /// An ImageUrl object with the image as a base64 data URL. + /// Thrown when the URL is null or empty. + /// Thrown when the image download fails. + Task DownloadAsImageUrlAsync(string url, string? detail = null, CancellationToken cancellationToken = default); +} diff --git a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAlertingService.cs b/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAlertingService.cs deleted file mode 100644 index 8a5c7864d..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAlertingService.cs +++ /dev/null @@ -1,148 +0,0 @@ -using ConduitLLM.Core.Models; - -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Service for managing alerts and notifications for image generation operations. - /// - public interface IImageGenerationAlertingService - { - /// - /// Registers a new alert rule. - /// - /// The alert rule to register. - /// The ID of the registered rule. - Task RegisterAlertRuleAsync(ImageGenerationAlertRule rule); - - /// - /// Updates an existing alert rule. - /// - /// The ID of the rule to update. - /// The updated rule. - Task UpdateAlertRuleAsync(string ruleId, ImageGenerationAlertRule rule); - - /// - /// Deletes an alert rule. - /// - /// The ID of the rule to delete. - Task DeleteAlertRuleAsync(string ruleId); - - /// - /// Gets all active alert rules. - /// - /// List of active alert rules. - Task> GetActiveRulesAsync(); - - /// - /// Evaluates current metrics against alert rules. - /// - /// Current metrics snapshot. - /// Cancellation token. - Task EvaluateMetricsAsync(ImageGenerationMetricsSnapshot metrics, CancellationToken cancellationToken = default); - - /// - /// Gets alert history within a time range. - /// - /// Start time. - /// End time. - /// Optional severity filter. - /// List of triggered alerts. - Task> GetAlertHistoryAsync(DateTime startTime, DateTime endTime, AlertSeverity? severity = null); - - /// - /// Acknowledges an alert. - /// - /// The alert ID to acknowledge. - /// Who acknowledged the alert. - /// Optional acknowledgment notes. - Task AcknowledgeAlertAsync(string alertId, string acknowledgedBy, string? notes = null); - - /// - /// Tests an alert rule to see if it would trigger. - /// - /// The rule to test. - /// Test result with details. - Task TestAlertRuleAsync(ImageGenerationAlertRule rule); - - /// - /// Gets active (unacknowledged) alerts. - /// - /// List of active alerts. - Task> GetActiveAlertsAsync(); - - /// - /// Registers a notification channel. - /// - /// The notification channel to register. - /// The ID of the registered channel. - Task RegisterNotificationChannelAsync(NotificationChannel channel); - - /// - /// Tests a notification channel. - /// - /// The channel ID to test. - /// Test result. - Task TestNotificationChannelAsync(string channelId); - } - - /// - /// Alert rule for image generation operations. - /// - public class ImageGenerationAlertRule - { - public string Id { get; set; } = Guid.NewGuid().ToString(); - public string Name { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public ImageGenerationMetricType MetricType { get; set; } - public AlertCondition Condition { get; set; } = new(); - public AlertSeverity Severity { get; set; } - public bool IsEnabled { get; set; } = true; - public TimeSpan CooldownPeriod { get; set; } = TimeSpan.FromMinutes(5); - public List NotificationChannelIds { get; set; } = new(); - public Dictionary Tags { get; set; } = new(); - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime? LastTriggeredAt { get; set; } - } - - /// - /// Types of metrics that can trigger alerts. - /// - public enum ImageGenerationMetricType - { - ErrorRate, - ResponseTime, - P95ResponseTime, - ProviderAvailability, - ProviderHealthScore, - QueueDepth, - QueueWaitTime, - GenerationRate, - CostRate, - CostPerImage, - ResourceUtilization, - ConsecutiveFailures, - SlaViolation, - VirtualKeyBudget, - StorageQuota - } - - - /// - /// Triggered alert instance. - /// - public class ImageGenerationAlert - { - public string Id { get; set; } = Guid.NewGuid().ToString(); - public ImageGenerationAlertRule Rule { get; set; } = new(); - public double MetricValue { get; set; } - public DateTime TriggeredAt { get; set; } = DateTime.UtcNow; - public string Message { get; set; } = string.Empty; - public Dictionary Details { get; set; } = new(); - public AlertState State { get; set; } = AlertState.Active; - public string? AcknowledgedBy { get; set; } - public DateTime? AcknowledgedAt { get; set; } - public string? AcknowledgmentNotes { get; set; } - public List NotificationResults { get; set; } = new(); - } - -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Metrics.cs b/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Metrics.cs deleted file mode 100644 index 946f30dd1..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Metrics.cs +++ /dev/null @@ -1,167 +0,0 @@ -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Performance metrics summary. - /// - public class ImageGenerationPerformanceMetrics - { - public double AverageResponseTimeMs { get; set; } - public double P50ResponseTimeMs { get; set; } - public double P95ResponseTimeMs { get; set; } - public double P99ResponseTimeMs { get; set; } - public Dictionary ResponseTimeByProvider { get; set; } = new(); - public Dictionary SuccessRateByProvider { get; set; } = new(); - public List Trends { get; set; } = new(); - } - - /// - /// Cost metrics summary. - /// - public class CostMetrics - { - public decimal TotalCost { get; set; } - public decimal AverageCostPerImage { get; set; } - public Dictionary CostByProvider { get; set; } = new(); - public Dictionary CostByModel { get; set; } = new(); - public List Trends { get; set; } = new(); - public decimal ProjectedMonthlyCost { get; set; } - } - - /// - /// Usage metrics summary. - /// - public class UsageMetrics - { - public int TotalRequests { get; set; } - public int TotalImages { get; set; } - public Dictionary RequestsByProvider { get; set; } = new(); - public Dictionary ImageSizeDistribution { get; set; } = new(); - public Dictionary RequestsByHour { get; set; } = new(); - public double PeakRequestsPerMinute { get; set; } - } - - /// - /// Quality metrics summary. - /// - public class ImageGenerationQualityMetrics - { - public double OverallSuccessRate { get; set; } - public Dictionary ErrorCountByType { get; set; } = new(); - public double SlaCompliancePercent { get; set; } - public int SlaViolations { get; set; } - public double AverageQueueWaitTime { get; set; } - } - - /// - /// Current capacity metrics. - /// - public class CapacityMetrics - { - public double PeakRequestsPerMinute { get; set; } - public double AverageRequestsPerMinute { get; set; } - public double CapacityUtilization { get; set; } - public int MaxConcurrentGenerations { get; set; } - public Dictionary ResourceUtilization { get; set; } = new(); - } - - /// - /// Provider metrics summary. - /// - public class ProviderMetricsSummary - { - public double AverageResponseTime { get; set; } - public double SuccessRate { get; set; } - public decimal TotalCost { get; set; } - public int TotalRequests { get; set; } - public double ErrorRate { get; set; } - } - - /// - /// Performance trend data. - /// - public class PerformanceTrend - { - public string Metric { get; set; } = string.Empty; - public ImageGenerationTrendDirection Direction { get; set; } - public double ChangePercent { get; set; } - } - - /// - /// Cost trend data. - /// - public class CostTrend - { - public DateTime Period { get; set; } - public decimal Cost { get; set; } - public double ChangePercent { get; set; } - } - - /// - /// Individual trend data point. - /// - public class UsageTrendPoint - { - public DateTime Timestamp { get; set; } - public int Requests { get; set; } - public int Images { get; set; } - public decimal Cost { get; set; } - public double SuccessRate { get; set; } - public double AverageResponseTime { get; set; } - } - - /// - /// Trend analysis results. - /// - public class TrendAnalysis - { - public ImageGenerationTrendDirection OverallTrend { get; set; } - public double GrowthRate { get; set; } - public string PeakUsagePattern { get; set; } = string.Empty; - public List SeasonalPatterns { get; set; } = new(); - public Dictionary ProviderTrends { get; set; } = new(); - } - - /// - /// Usage forecast. - /// - public class UsageForecast - { - public int ForecastDays { get; set; } - public decimal ProjectedCost { get; set; } - public int ProjectedRequests { get; set; } - public double ConfidenceLevel { get; set; } - public List ForecastPoints { get; set; } = new(); - } - - /// - /// Forecast data point. - /// - public class ForecastPoint - { - public DateTime Date { get; set; } - public int ExpectedRequests { get; set; } - public int LowerBound { get; set; } - public int UpperBound { get; set; } - } - - /// - /// Capacity forecast. - /// - public class CapacityForecast - { - public DateTime ProjectedCapacityLimit { get; set; } - public double ProjectedPeakLoad { get; set; } - public List Projections { get; set; } = new(); - } - - /// - /// Capacity projection point. - /// - public class CapacityProjection - { - public DateTime Date { get; set; } - public double ExpectedLoad { get; set; } - public double CapacityUtilization { get; set; } - public bool ExceedsCapacity { get; set; } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Reports.cs b/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Reports.cs deleted file mode 100644 index 7b86a9111..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Reports.cs +++ /dev/null @@ -1,148 +0,0 @@ -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Comprehensive analytics report for image generation. - /// - public class ImageGenerationAnalyticsReport - { - public DateTime StartTime { get; set; } - public DateTime EndTime { get; set; } - public ExecutiveSummary Summary { get; set; } = new(); - public ImageGenerationPerformanceMetrics Performance { get; set; } = new(); - public CostMetrics Cost { get; set; } = new(); - public UsageMetrics Usage { get; set; } = new(); - public ImageGenerationQualityMetrics Quality { get; set; } = new(); - public List KeyInsights { get; set; } = new(); - } - - /// - /// Executive summary of analytics. - /// - public class ExecutiveSummary - { - public int TotalGenerations { get; set; } - public int TotalImages { get; set; } - public decimal TotalCost { get; set; } - public double OverallSuccessRate { get; set; } - public double AverageResponseTime { get; set; } - public int UniqueVirtualKeys { get; set; } - public Dictionary TopProviders { get; set; } = new(); - public List CriticalIssues { get; set; } = new(); - } - - /// - /// Provider comparison report. - /// - public class ProviderComparisonReport - { - public DateTime GeneratedAt { get; set; } - public int TimeWindowHours { get; set; } - public List Providers { get; set; } = new(); - public string BestPerformanceProvider { get; set; } = string.Empty; - public string BestCostEfficiencyProvider { get; set; } = string.Empty; - public string MostReliableProvider { get; set; } = string.Empty; - public List Recommendations { get; set; } = new(); - } - - /// - /// Individual provider comparison data. - /// - public class ProviderComparison - { - public string ProviderName { get; set; } = string.Empty; - public double PerformanceScore { get; set; } - public double ReliabilityScore { get; set; } - public double CostEfficiencyScore { get; set; } - public double OverallScore { get; set; } - public ProviderMetricsSummary Metrics { get; set; } = new(); - public List Strengths { get; set; } = new(); - public List Weaknesses { get; set; } = new(); - } - - /// - /// Provider recommendation. - /// - public class ProviderRecommendation - { - public string Provider { get; set; } = string.Empty; - public string Action { get; set; } = string.Empty; - public string Reason { get; set; } = string.Empty; - public double ExpectedImprovement { get; set; } - } - - /// - /// Usage trend report. - /// - public class UsageTrendReport - { - public DateTime GeneratedAt { get; set; } - public TimeGranularity Granularity { get; set; } - public List TrendData { get; set; } = new(); - public TrendAnalysis Analysis { get; set; } = new(); - public UsageForecast Forecast { get; set; } = new(); - } - - /// - /// Error analysis report. - /// - public class ErrorAnalysisReport - { - public DateTime GeneratedAt { get; set; } - public int TotalErrors { get; set; } - public double ErrorRate { get; set; } - public Dictionary ErrorPatterns { get; set; } = new(); - public List Correlations { get; set; } = new(); - public Dictionary> TopErrors { get; set; } = new(); - public List RootCauseAnalysis { get; set; } = new(); - } - - /// - /// Capacity planning report. - /// - public class CapacityPlanningReport - { - public DateTime GeneratedAt { get; set; } - public CapacityMetrics CurrentCapacity { get; set; } = new(); - public CapacityForecast Forecast { get; set; } = new(); - public List Recommendations { get; set; } = new(); - public Dictionary ProviderCapacities { get; set; } = new(); - } - - /// - /// Virtual key usage report. - /// - public class VirtualKeyUsageReport - { - public DateTime GeneratedAt { get; set; } - public List KeyUsages { get; set; } = new(); - public decimal TotalSpend { get; set; } - public Dictionary UsageTrends { get; set; } = new(); - public List BudgetAlerts { get; set; } = new(); - } - - /// - /// Anomaly detection report. - /// - public class AnomalyDetectionReport - { - public DateTime GeneratedAt { get; set; } - public List Anomalies { get; set; } = new(); - public Dictionary Patterns { get; set; } = new(); - public List AffectedProviders { get; set; } = new(); - public AnomalySummary Summary { get; set; } = new(); - } - - /// - /// Cost optimization report. - /// - public class CostOptimizationReport - { - public DateTime GeneratedAt { get; set; } - public decimal CurrentMonthlyCost { get; set; } - public decimal PotentialSavings { get; set; } - public double SavingsPercentage { get; set; } - public List Opportunities { get; set; } = new(); - public Dictionary CostByVirtualKey { get; set; } = new(); - public List Anomalies { get; set; } = new(); - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Supporting.cs b/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Supporting.cs deleted file mode 100644 index 9f1ceb265..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.Supporting.cs +++ /dev/null @@ -1,215 +0,0 @@ -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Key insight from analytics. - /// - public class KeyInsight - { - public string Type { get; set; } = string.Empty; - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public InsightSeverity Severity { get; set; } - public string? Recommendation { get; set; } - public Dictionary Data { get; set; } = new(); - } - - /// - /// Insight severity levels. - /// - public enum InsightSeverity - { - Info, - Opportunity, - Warning, - Critical - } - - /// - /// Cost optimization opportunity. - /// - public class CostOptimizationOpportunity - { - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public decimal PotentialSavings { get; set; } - public string Implementation { get; set; } = string.Empty; - public OpportunityImpact Impact { get; set; } - public OpportunityEffort Effort { get; set; } - } - - /// - /// Impact level of optimization opportunity. - /// - public enum OpportunityImpact - { - Low, - Medium, - High - } - - /// - /// Implementation effort required. - /// - public enum OpportunityEffort - { - Low, - Medium, - High - } - - /// - /// Cost anomaly detection. - /// - public class CostAnomaly - { - public DateTime DetectedAt { get; set; } - public string Type { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public decimal AnomalousAmount { get; set; } - public decimal ExpectedAmount { get; set; } - public double DeviationPercent { get; set; } - } - - /// - /// Error pattern analysis. - /// - public class ErrorPattern - { - public string ErrorType { get; set; } = string.Empty; - public int Occurrences { get; set; } - public double Frequency { get; set; } - public List AffectedProviders { get; set; } = new(); - public string Pattern { get; set; } = string.Empty; - public bool IsRetryable { get; set; } - } - - /// - /// Error correlation finding. - /// - public class ErrorCorrelation - { - public string Factor { get; set; } = string.Empty; - public double CorrelationStrength { get; set; } - public string Description { get; set; } = string.Empty; - } - - /// - /// Individual error occurrence. - /// - public class ErrorOccurrence - { - public DateTime Timestamp { get; set; } - public string Provider { get; set; } = string.Empty; - public string ErrorMessage { get; set; } = string.Empty; - public Dictionary Context { get; set; } = new(); - } - - /// - /// Capacity recommendation. - /// - public class CapacityRecommendation - { - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public CapacityAction Action { get; set; } - public DateTime RecommendedBy { get; set; } - public string Justification { get; set; } = string.Empty; - } - - /// - /// Capacity action type. - /// - public enum CapacityAction - { - ScaleUp, - ScaleOut, - OptimizeUsage, - AddProvider, - ImplementCaching, - EnableRateLimiting - } - - /// - /// Provider capacity information. - /// - public class ProviderCapacity - { - public string ProviderName { get; set; } = string.Empty; - public int RateLimitPerMinute { get; set; } - public double CurrentUtilization { get; set; } - public int AvailableCapacity { get; set; } - public bool IsAtCapacity { get; set; } - } - - /// - /// Individual virtual key usage. - /// - public class VirtualKeyUsage - { - public int VirtualKeyId { get; set; } - public string KeyName { get; set; } = string.Empty; - public int TotalRequests { get; set; } - public int TotalImages { get; set; } - public decimal TotalCost { get; set; } - public decimal BudgetRemaining { get; set; } - public double BudgetUtilization { get; set; } - public DateTime LastUsed { get; set; } - public Dictionary RequestsByProvider { get; set; } = new(); - } - - /// - /// Usage trend for a virtual key. - /// - public class UsageTrend - { - public ImageGenerationTrendDirection Direction { get; set; } - public double GrowthRate { get; set; } - public DateTime ProjectedBudgetExhaustion { get; set; } - } - - /// - /// Budget alert for virtual key. - /// - public class BudgetAlert - { - public int VirtualKeyId { get; set; } - public string AlertType { get; set; } = string.Empty; - public double BudgetUtilization { get; set; } - public DateTime ProjectedExhaustion { get; set; } - } - - /// - /// Performance anomaly detection. - /// - public class PerformanceAnomaly - { - public DateTime DetectedAt { get; set; } - public string Type { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public double AnomalyScore { get; set; } - public Dictionary Metrics { get; set; } = new(); - public string? PossibleCause { get; set; } - } - - /// - /// Anomaly pattern. - /// - public class AnomalyPattern - { - public string PatternType { get; set; } = string.Empty; - public int Occurrences { get; set; } - public TimeSpan AverageDuration { get; set; } - public double RecurrenceProbability { get; set; } - } - - /// - /// Anomaly summary. - /// - public class AnomalySummary - { - public int TotalAnomalies { get; set; } - public int CriticalAnomalies { get; set; } - public double SystemStability { get; set; } - public List RecommendedActions { get; set; } = new(); - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.cs b/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.cs deleted file mode 100644 index 61f5ea672..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationAnalyticsService.cs +++ /dev/null @@ -1,117 +0,0 @@ -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Service for analyzing image generation performance and providing insights. - /// - public partial interface IImageGenerationAnalyticsService - { - /// - /// Gets a comprehensive analytics report for a time period. - /// - /// Start time for the report. - /// End time for the report. - /// Cancellation token. - /// Comprehensive analytics report. - Task GetAnalyticsReportAsync( - DateTime startTime, - DateTime endTime, - CancellationToken cancellationToken = default); - - /// - /// Gets provider comparison analytics. - /// - /// Time window in hours. - /// Cancellation token. - /// Provider comparison report. - Task GetProviderComparisonAsync( - int timeWindowHours = 24, - CancellationToken cancellationToken = default); - - /// - /// Gets cost optimization recommendations. - /// - /// Time window in days to analyze. - /// Cancellation token. - /// Cost optimization recommendations. - Task GetCostOptimizationRecommendationsAsync( - int timeWindowDays = 7, - CancellationToken cancellationToken = default); - - /// - /// Gets usage trend analysis. - /// - /// Time granularity (hourly, daily, weekly). - /// Number of periods to analyze. - /// Cancellation token. - /// Usage trend report. - Task GetUsageTrendsAsync( - TimeGranularity granularity, - int periods = 30, - CancellationToken cancellationToken = default); - - /// - /// Gets error analysis and patterns. - /// - /// Time window in hours. - /// Cancellation token. - /// Error analysis report. - Task GetErrorAnalysisAsync( - int timeWindowHours = 24, - CancellationToken cancellationToken = default); - - /// - /// Gets capacity planning insights. - /// - /// Days to forecast ahead. - /// Cancellation token. - /// Capacity planning report. - Task GetCapacityPlanningInsightsAsync( - int forecastDays = 30, - CancellationToken cancellationToken = default); - - /// - /// Gets virtual key usage analytics. - /// - /// Optional virtual key ID filter. - /// Time window in days. - /// Cancellation token. - /// Virtual key usage report. - Task GetVirtualKeyUsageAnalyticsAsync( - int? virtualKeyId = null, - int timeWindowDays = 30, - CancellationToken cancellationToken = default); - - /// - /// Gets performance anomaly detection results. - /// - /// Time window in hours. - /// Cancellation token. - /// Anomaly detection report. - Task DetectPerformanceAnomaliesAsync( - int timeWindowHours = 24, - CancellationToken cancellationToken = default); - } - - - /// - /// Time granularity for analysis. - /// - public enum TimeGranularity - { - Hourly, - Daily, - Weekly, - Monthly - } - - /// - /// Trend direction for image generation metrics. - /// - public enum ImageGenerationTrendDirection - { - Declining, - Stable, - Growing, - Volatile - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationMetricsCollector.cs b/Shared/ConduitLLM.Core/Interfaces/IImageGenerationMetricsCollector.cs deleted file mode 100644 index a762c71b4..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IImageGenerationMetricsCollector.cs +++ /dev/null @@ -1,233 +0,0 @@ -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Collects comprehensive metrics for image generation operations. - /// - public interface IImageGenerationMetricsCollector - { - /// - /// Records the start of an image generation operation. - /// - /// Unique operation identifier. - /// Provider name. - /// Model name. - /// Number of images requested. - /// Virtual key ID. - void RecordGenerationStart(string operationId, string provider, string model, int imageCount, int virtualKeyId); - - /// - /// Records the completion of an image generation operation. - /// - /// Unique operation identifier. - /// Whether the operation succeeded. - /// Number of images successfully generated. - /// Total cost of the operation. - /// Error message if failed. - void RecordGenerationComplete(string operationId, bool success, int imagesGenerated, decimal cost, string? error = null); - - /// - /// Records provider-specific performance metrics. - /// - /// Provider name. - /// Model name. - /// Response time in milliseconds. - /// Time spent in queue in milliseconds. - void RecordProviderPerformance(string provider, string model, double responseTimeMs, double queueTimeMs); - - /// - /// Records image download metrics. - /// - /// Provider name. - /// Download time in milliseconds. - /// Size of the image in bytes. - /// Whether the download succeeded. - void RecordImageDownload(string provider, double downloadTimeMs, long imageSizeBytes, bool success); - - /// - /// Records storage operation metrics. - /// - /// Type of storage (S3, InMemory, etc). - /// Type of operation (Store, Retrieve, Delete). - /// Duration in milliseconds. - /// Size in bytes. - /// Whether the operation succeeded. - void RecordStorageOperation(string storageType, string operationType, double durationMs, long sizeBytes, bool success); - - /// - /// Records queue metrics. - /// - /// Name of the queue. - /// Current queue depth. - /// Age of the oldest item in milliseconds. - void RecordQueueMetrics(string queueName, int depth, double oldestItemAgeMs); - - /// - /// Records resource utilization during image generation. - /// - /// CPU usage percentage. - /// Memory usage in MB. - /// Number of active generations. - /// Number of thread pool threads. - void RecordResourceUtilization(double cpuPercent, double memoryMb, int activeGenerations, int threadPoolThreads); - - /// - /// Records virtual key usage metrics. - /// - /// Virtual key ID. - /// Number of images generated. - /// Cost incurred. - /// Remaining budget. - void RecordVirtualKeyUsage(int virtualKeyId, int imagesGenerated, decimal cost, decimal remainingBudget); - - /// - /// Records provider health score. - /// - /// Provider name. - /// Health score (0-1). - /// Whether the provider is considered healthy. - /// Last error message if any. - void RecordProviderHealth(string provider, double healthScore, bool isHealthy, string? lastError = null); - - /// - /// Records model-specific metrics. - /// - /// Model name. - /// Image size requested. - /// Quality setting. - /// Generation time in milliseconds. - void RecordModelMetrics(string model, string imageSize, string quality, double generationTimeMs); - - /// - /// Gets current metrics snapshot. - /// - /// Cancellation token. - /// Current metrics snapshot. - Task GetMetricsSnapshotAsync(CancellationToken cancellationToken = default); - - /// - /// Gets provider-specific metrics. - /// - /// Provider name. - /// Time window in minutes. - /// Cancellation token. - /// Provider-specific metrics. - Task GetProviderMetricsAsync(string provider, int timeWindowMinutes = 60, CancellationToken cancellationToken = default); - - /// - /// Gets SLA compliance metrics. - /// - /// Time window in hours. - /// Cancellation token. - /// SLA compliance summary. - Task GetSlaComplianceAsync(int timeWindowHours = 24, CancellationToken cancellationToken = default); - } - - /// - /// Snapshot of current image generation metrics. - /// - public class ImageGenerationMetricsSnapshot - { - public DateTime Timestamp { get; set; } - public int ActiveGenerations { get; set; } - public double GenerationsPerMinute { get; set; } - public double AverageResponseTimeMs { get; set; } - public double P95ResponseTimeMs { get; set; } - public double SuccessRate { get; set; } - public Dictionary ProviderStatuses { get; set; } = new(); - public QueueMetrics QueueMetrics { get; set; } = new(); - public ResourceMetrics ResourceMetrics { get; set; } = new(); - public Dictionary ErrorCounts { get; set; } = new(); - public decimal TotalCostLastHour { get; set; } - public int TotalImagesLastHour { get; set; } - } - - - /// - /// Model-specific metrics. - /// - public class ModelMetrics - { - public string ModelName { get; set; } = string.Empty; - public int RequestCount { get; set; } - public double AverageResponseTimeMs { get; set; } - public decimal TotalCost { get; set; } - public int TotalImages { get; set; } - public Dictionary SizeDistribution { get; set; } = new(); - public Dictionary QualityDistribution { get; set; } = new(); - } - - /// - /// Provider status information. - /// - public class ProviderStatus - { - public bool IsHealthy { get; set; } - public double HealthScore { get; set; } - public int ActiveRequests { get; set; } - public double AverageResponseTimeMs { get; set; } - public int ConsecutiveFailures { get; set; } - public DateTime? LastSuccessAt { get; set; } - public DateTime? LastFailureAt { get; set; } - public string? LastError { get; set; } - } - - /// - /// Queue metrics. - /// - public class QueueMetrics - { - public int TotalDepth { get; set; } - public Dictionary QueueDepthByPriority { get; set; } = new(); - public double AverageWaitTimeMs { get; set; } - public double MaxWaitTimeMs { get; set; } - public int ProcessingRate { get; set; } - public DateTime? OldestItemTimestamp { get; set; } - } - - /// - /// Resource utilization metrics. - /// - public class ResourceMetrics - { - public double CpuUsagePercent { get; set; } - public double MemoryUsageMb { get; set; } - public double MemoryUsagePercent { get; set; } - public int ThreadPoolThreads { get; set; } - public int ActiveConnections { get; set; } - public double StorageUsedGb { get; set; } - public double StorageBandwidthMbps { get; set; } - } - - /// - /// SLA compliance summary. - /// - public class SlaComplianceSummary - { - public DateTime PeriodStart { get; set; } - public DateTime PeriodEnd { get; set; } - public double AvailabilityPercent { get; set; } - public bool MeetsAvailabilitySla { get; set; } - public double P95ResponseTimeMs { get; set; } - public bool MeetsResponseTimeSla { get; set; } - public double ErrorRatePercent { get; set; } - public bool MeetsErrorRateSla { get; set; } - public int TotalRequests { get; set; } - public int SuccessfulRequests { get; set; } - public int FailedRequests { get; set; } - public List Violations { get; set; } = new(); - } - - /// - /// SLA violation record. - /// - public class SlaViolation - { - public DateTime Timestamp { get; set; } - public string ViolationType { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public double ActualValue { get; set; } - public double ThresholdValue { get; set; } - public TimeSpan Duration { get; set; } - public string? AffectedProvider { get; set; } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Interfaces/ILLMClientFactory.cs b/Shared/ConduitLLM.Core/Interfaces/ILLMClientFactory.cs index b36807ec5..ba774decf 100644 --- a/Shared/ConduitLLM.Core/Interfaces/ILLMClientFactory.cs +++ b/Shared/ConduitLLM.Core/Interfaces/ILLMClientFactory.cs @@ -8,23 +8,24 @@ namespace ConduitLLM.Core.Interfaces; public interface ILLMClientFactory { /// - /// Gets an appropriate ILLMClient instance for the specified model alias based on the loaded configuration. + /// Asynchronously gets an appropriate ILLMClient instance for the specified model alias. /// /// The model alias specified in the request (e.g., "gpt-4-turbo"). + /// Cancellation token. /// An instance of ILLMClient capable of handling the request for the specified model. /// Thrown if the configuration for the model alias or its provider is invalid or missing. /// Thrown if the provider specified in the configuration is not supported by this factory. - ILLMClient GetClient(string modelAlias); + Task GetClientAsync(string modelAlias, CancellationToken cancellationToken = default); - /// - /// Gets an ILLMClient instance for the specified provider ID directly. + /// Asynchronously gets an ILLMClient instance for the specified provider ID directly. /// /// The ID of the provider. + /// Cancellation token. /// An instance of ILLMClient for the specified provider. /// Thrown if the configuration for the provider is invalid or missing. /// Thrown if the specified provider is not supported by this factory. - ILLMClient GetClientByProviderId(int providerId); + Task GetClientByProviderIdAsync(int providerId, CancellationToken cancellationToken = default); /// /// Gets provider metadata for the specified provider type without requiring credentials. @@ -34,14 +35,14 @@ public interface ILLMClientFactory IProviderMetadata? GetProviderMetadata(ConduitLLM.Configuration.ProviderType providerType); /// - /// Gets an ILLMClient instance for the specified provider type directly. - /// This method looks up the provider by its enum type rather than database ID. + /// Asynchronously gets an ILLMClient instance for the specified provider type directly. /// /// The provider type enum value. + /// Cancellation token. /// An instance of ILLMClient for the specified provider type. /// Thrown if the configuration for the provider is invalid or missing. /// Thrown if the specified provider type is not supported by this factory. - ILLMClient GetClientByProviderType(ConduitLLM.Configuration.ProviderType providerType); + Task GetClientByProviderTypeAsync(ConduitLLM.Configuration.ProviderType providerType, CancellationToken cancellationToken = default); /// /// Creates a lightweight ILLMClient instance for testing provider credentials. diff --git a/Shared/ConduitLLM.Core/Interfaces/IModelCapabilityDetector.cs b/Shared/ConduitLLM.Core/Interfaces/IModelCapabilityDetector.cs deleted file mode 100644 index 46ad8b77e..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IModelCapabilityDetector.cs +++ /dev/null @@ -1,41 +0,0 @@ -using ConduitLLM.Core.Models; - -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Interface for detecting and validating model capabilities, particularly for - /// specialized features like vision/multimodal support. - /// - public interface IModelCapabilityDetector - { - /// - /// Determines if a model has vision (image processing) capabilities. - /// - /// The name of the model to check - /// True if the model supports vision input, false otherwise - bool HasVisionCapability(string modelName); - - /// - /// Determines if a chat completion request contains image content that - /// requires a vision-capable model. - /// - /// The chat completion request to check - /// True if the request contains image content, false otherwise - bool ContainsImageContent(ChatCompletionRequest request); - - /// - /// Gets a list of all available models that support vision capabilities. - /// - /// A collection of model names that support vision - IEnumerable GetVisionCapableModels(); - - /// - /// Validates that a request can be processed by the specified model. - /// - /// The chat completion request to validate - /// The name of the model to check - /// Error message if validation fails - /// True if the request is valid for the model, false otherwise - bool ValidateRequestForModel(ChatCompletionRequest request, string modelName, out string errorMessage); - } -} diff --git a/Shared/ConduitLLM.Core/Interfaces/IProviderErrorTrackingService.cs b/Shared/ConduitLLM.Core/Interfaces/IProviderErrorTrackingService.cs index a77ebb7ac..ced7ad3c8 100644 --- a/Shared/ConduitLLM.Core/Interfaces/IProviderErrorTrackingService.cs +++ b/Shared/ConduitLLM.Core/Interfaces/IProviderErrorTrackingService.cs @@ -55,7 +55,8 @@ Task> GetRecentErrorsAsync( /// Clear all errors for a key (used when re-enabling) /// /// ID of the key to clear errors for - Task ClearErrorsForKeyAsync(int keyId); + /// Optional provider ID to also clean up the provider's disabled keys tracking + Task ClearErrorsForKeyAsync(int keyId, int? providerId = null); /// /// Get detailed error information for a specific key diff --git a/Shared/ConduitLLM.Core/Interfaces/IProviderToolCache.cs b/Shared/ConduitLLM.Core/Interfaces/IProviderToolCache.cs new file mode 100644 index 000000000..0d0631510 --- /dev/null +++ b/Shared/ConduitLLM.Core/Interfaces/IProviderToolCache.cs @@ -0,0 +1,49 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Entities; + +namespace ConduitLLM.Core.Interfaces; + +/// +/// Cache interface for provider tool lookups used in the billing pipeline. +/// Caches active tools per provider type to avoid per-request database queries. +/// +public interface IProviderToolCache +{ + /// + /// Gets all active tools for a provider, using cache with database fallback. + /// + /// The provider type to look up tools for + /// Function to load from database on cache miss + /// List of active provider tools + Task> GetActiveToolsForProviderAsync( + ProviderType providerType, + Func>> databaseFallback); + + /// + /// Invalidates all cached tools for a specific provider type. + /// + Task InvalidateProviderAsync(ProviderType providerType); + + /// + /// Clears all cached provider tool entries. + /// + Task ClearAllAsync(); + + /// + /// Gets cache statistics. + /// + Task GetStatsAsync(); +} + +/// +/// Statistics for the provider tool cache. +/// +public class ProviderToolCacheStats +{ + public long HitCount { get; set; } + public long MissCount { get; set; } + public long InvalidationCount { get; set; } + public double HitRate => HitCount + MissCount > 0 ? (double)HitCount / (HitCount + MissCount) : 0; + public DateTime LastResetTime { get; set; } = DateTime.UtcNow; + public long EntryCount { get; set; } +} diff --git a/Shared/ConduitLLM.Core/Interfaces/IRedisErrorStore.cs b/Shared/ConduitLLM.Core/Interfaces/IRedisErrorStore.cs index c4f0fdfff..e0859536f 100644 --- a/Shared/ConduitLLM.Core/Interfaces/IRedisErrorStore.cs +++ b/Shared/ConduitLLM.Core/Interfaces/IRedisErrorStore.cs @@ -60,12 +60,19 @@ public interface IRedisErrorStore Task MarkProviderDisabledAsync(int providerId, DateTime disabledAt, string reason); /// - /// Add a key to the provider's disabled keys list + /// Add a key to the provider's disabled keys set /// /// The provider ID /// The key credential ID Task AddDisabledKeyToProviderAsync(int providerId, int keyId); + /// + /// Remove a key from the provider's disabled keys set (when re-enabled) + /// + /// The provider ID + /// The key credential ID + Task RemoveDisabledKeyFromProviderAsync(int providerId, int keyId); + /// /// Get recent errors from the feed /// @@ -83,10 +90,11 @@ public interface IRedisErrorStore Task> GetErrorCountsByKeysAsync(int providerId, IEnumerable keyIds, TimeSpan window); /// - /// Clear all error data for a key + /// Clear all error data for a key and optionally remove from provider's disabled keys /// /// The key credential ID - Task ClearErrorsForKeyAsync(int keyId); + /// Optional provider ID to also clean up the provider's disabled keys set + Task ClearErrorsForKeyAsync(int keyId, int? providerId = null); /// /// Get all error data for a key @@ -187,5 +195,6 @@ public class ErrorStatsData public int FatalErrors { get; set; } public int Warnings { get; set; } public Dictionary ErrorsByType { get; set; } = new(); + public Dictionary ErrorsByProvider { get; set; } = new(); } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Interfaces/IVideoGenerationService.cs b/Shared/ConduitLLM.Core/Interfaces/IVideoGenerationService.cs deleted file mode 100644 index 73284b036..000000000 --- a/Shared/ConduitLLM.Core/Interfaces/IVideoGenerationService.cs +++ /dev/null @@ -1,81 +0,0 @@ -using ConduitLLM.Core.Models; - -namespace ConduitLLM.Core.Interfaces -{ - /// - /// Interface for video generation services. - /// Provides methods for generating videos synchronously and asynchronously. - /// - public interface IVideoGenerationService - { - /// - /// Generates a video synchronously based on the provided request. - /// This method blocks until the video is generated or an error occurs. - /// - /// The video generation request. - /// The virtual key for authentication and tracking. - /// Cancellation token. - /// The video generation response containing the generated video data. - Task GenerateVideoAsync( - VideoGenerationRequest request, - string virtualKey, - CancellationToken cancellationToken = default); - - /// - /// Initiates an asynchronous video generation task. - /// Returns immediately with a task ID that can be used to track progress. - /// - /// The video generation request. - /// The virtual key for authentication and tracking. - /// Cancellation token. - /// A response containing the task ID for tracking the generation progress. - Task GenerateVideoWithTaskAsync( - VideoGenerationRequest request, - string virtualKey, - CancellationToken cancellationToken = default); - - /// - /// Gets the current status of a video generation task. - /// - /// The unique identifier of the generation task. - /// The virtual key for authentication. - /// Cancellation token. - /// The current status and result (if completed) of the video generation task. - Task GetVideoGenerationStatusAsync( - string taskId, - string virtualKey, - CancellationToken cancellationToken = default); - - /// - /// Cancels an in-progress video generation task. - /// - /// The unique identifier of the generation task to cancel. - /// The virtual key for authentication. - /// Cancellation token. - /// True if the task was successfully cancelled, false otherwise. - Task CancelVideoGenerationAsync( - string taskId, - string virtualKey, - CancellationToken cancellationToken = default); - - /// - /// Validates if a video generation request is supported by the specified model. - /// - /// The video generation request to validate. - /// Cancellation token. - /// True if the request is valid and supported, false otherwise. - Task ValidateRequestAsync( - VideoGenerationRequest request, - CancellationToken cancellationToken = default); - - /// - /// Gets the estimated cost for a video generation request. - /// - /// The video generation request. - /// Cancellation token. - /// The estimated cost in the system's currency units. - Task EstimateCostAsync( - VideoGenerationRequest request, - CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Metrics/AuthMetrics.cs b/Shared/ConduitLLM.Core/Metrics/AuthMetrics.cs new file mode 100644 index 000000000..6d537b10d --- /dev/null +++ b/Shared/ConduitLLM.Core/Metrics/AuthMetrics.cs @@ -0,0 +1,81 @@ +using Prometheus; + +namespace ConduitLLM.Core.Metrics; + +/// +/// Shared Prometheus metrics for authentication operations. +/// Instantiate with a service prefix (e.g., "gateway", "admin") to create +/// service-scoped counters and histograms. +/// +public class AuthMetrics +{ + /// + /// Total authentication attempts by scheme and result. + /// + public Counter AuthAttempts { get; } + + /// + /// Authentication duration by scheme. + /// + public Histogram AuthDuration { get; } + + /// + /// Authentication failures by scheme and reason. + /// + public Counter AuthFailures { get; } + + /// + /// Creates a new instance with metrics prefixed by the given service name. + /// + /// Service prefix for metric names (e.g., "gateway", "admin"). + public AuthMetrics(string servicePrefix) + { + AuthAttempts = Prometheus.Metrics + .CreateCounter($"conduit_{servicePrefix}_auth_attempts_total", "Total authentication attempts", + new CounterConfiguration + { + LabelNames = new[] { "scheme", "result" } + }); + + AuthDuration = Prometheus.Metrics + .CreateHistogram($"conduit_{servicePrefix}_auth_duration_seconds", "Authentication duration", + new HistogramConfiguration + { + LabelNames = new[] { "scheme" }, + Buckets = Histogram.ExponentialBuckets(0.0001, 2, 14) // 0.1ms to ~1.6s + }); + + AuthFailures = Prometheus.Metrics + .CreateCounter($"conduit_{servicePrefix}_auth_failures_total", "Authentication failures by reason", + new CounterConfiguration + { + LabelNames = new[] { "scheme", "reason" } + }); + } + + /// Records a successful authentication attempt. + public void RecordSuccess(string scheme) + => AuthAttempts.WithLabels(scheme, "success").Inc(); + + /// Records a failed authentication attempt. + public void RecordFailure(string scheme, string reason) + { + AuthAttempts.WithLabels(scheme, "failure").Inc(); + AuthFailures.WithLabels(scheme, reason).Inc(); + } + + /// Records an authentication attempt with no result (pass-through). + public void RecordNoResult(string scheme) + => AuthAttempts.WithLabels(scheme, "no_result").Inc(); + + /// Records an authentication error. + public void RecordError(string scheme) + { + AuthAttempts.WithLabels(scheme, "error").Inc(); + AuthFailures.WithLabels(scheme, "error").Inc(); + } + + /// Records the duration of an authentication operation. + public void RecordDuration(string scheme, double durationSeconds) + => AuthDuration.WithLabels(scheme).Observe(durationSeconds); +} diff --git a/Shared/ConduitLLM.Core/Metrics/CacheMetrics.cs b/Shared/ConduitLLM.Core/Metrics/CacheMetrics.cs new file mode 100644 index 000000000..31290b9f2 --- /dev/null +++ b/Shared/ConduitLLM.Core/Metrics/CacheMetrics.cs @@ -0,0 +1,87 @@ +using Prometheus; + +namespace ConduitLLM.Core.Metrics; + +/// +/// Shared Prometheus metrics for cache operations. +/// Instantiate with a service prefix (e.g., "gateway", "admin") to create +/// service-scoped counters and histograms. +/// +public class CacheMetrics +{ + /// + /// Total cache lookups by cache name and result (hit/miss). + /// + public Counter CacheLookups { get; } + + /// + /// Cache operation latency by cache name and operation type. + /// + public Histogram CacheLatency { get; } + + /// + /// Total cache invalidations by cache name and reason. + /// + public Counter CacheInvalidations { get; } + + /// + /// Total cache errors by cache name and operation. + /// + public Counter CacheErrors { get; } + + /// + /// Creates a new instance with metrics prefixed by the given service name. + /// + /// Service prefix for metric names (e.g., "gateway", "admin"). + public CacheMetrics(string servicePrefix) + { + CacheLookups = Prometheus.Metrics + .CreateCounter($"conduit_{servicePrefix}_cache_lookups_total", "Total cache lookups", + new CounterConfiguration + { + LabelNames = new[] { "cache", "result" } + }); + + CacheLatency = Prometheus.Metrics + .CreateHistogram($"conduit_{servicePrefix}_cache_latency_seconds", "Cache operation latency", + new HistogramConfiguration + { + LabelNames = new[] { "cache", "operation" }, + Buckets = Histogram.ExponentialBuckets(0.0001, 2, 14) // 0.1ms to ~1.6s + }); + + CacheInvalidations = Prometheus.Metrics + .CreateCounter($"conduit_{servicePrefix}_cache_invalidations_total", "Total cache invalidations", + new CounterConfiguration + { + LabelNames = new[] { "cache", "reason" } + }); + + CacheErrors = Prometheus.Metrics + .CreateCounter($"conduit_{servicePrefix}_cache_errors_total", "Total cache operation errors", + new CounterConfiguration + { + LabelNames = new[] { "cache", "operation" } + }); + } + + /// Records a cache hit for the specified cache. + public void RecordHit(string cacheName) + => CacheLookups.WithLabels(cacheName, "hit").Inc(); + + /// Records a cache miss for the specified cache. + public void RecordMiss(string cacheName) + => CacheLookups.WithLabels(cacheName, "miss").Inc(); + + /// Records the latency of a cache operation. + public void RecordLatency(string cacheName, string operation, double durationSeconds) + => CacheLatency.WithLabels(cacheName, operation).Observe(durationSeconds); + + /// Records a cache invalidation event. + public void RecordInvalidation(string cacheName, string reason = "explicit") + => CacheInvalidations.WithLabels(cacheName, reason).Inc(); + + /// Records a cache operation error. + public void RecordError(string cacheName, string operation) + => CacheErrors.WithLabels(cacheName, operation).Inc(); +} diff --git a/Shared/ConduitLLM.Core/Metrics/EventPublishingMetrics.cs b/Shared/ConduitLLM.Core/Metrics/EventPublishingMetrics.cs new file mode 100644 index 000000000..92890435d --- /dev/null +++ b/Shared/ConduitLLM.Core/Metrics/EventPublishingMetrics.cs @@ -0,0 +1,47 @@ +using Prometheus; + +namespace ConduitLLM.Core.Metrics +{ + /// + /// Prometheus metrics for MassTransit event publishing operations. + /// Tracks publish success/failure rates and latency. + /// + public static class EventPublishingMetrics + { + /// + /// Total event publish operations by event type and status. + /// + public static readonly Counter EventsPublished = Prometheus.Metrics + .CreateCounter("conduit_events_published_total", "Total event publish operations", + new CounterConfiguration + { + LabelNames = new[] { "event_type", "status" } // status: success, failure, skipped + }); + + /// + /// Event publish duration in seconds. + /// + public static readonly Histogram EventPublishDuration = Prometheus.Metrics + .CreateHistogram("conduit_event_publish_duration_seconds", "Event publish duration", + new HistogramConfiguration + { + LabelNames = new[] { "event_type" }, + Buckets = Histogram.ExponentialBuckets(0.001, 2, 12) // 1ms to ~4s + }); + + // Convenience methods + + public static void RecordSuccess(string eventType, double? durationSeconds = null) + { + EventsPublished.WithLabels(eventType, "success").Inc(); + if (durationSeconds.HasValue) + EventPublishDuration.WithLabels(eventType).Observe(durationSeconds.Value); + } + + public static void RecordFailure(string eventType) + => EventsPublished.WithLabels(eventType, "failure").Inc(); + + public static void RecordSkipped(string eventType) + => EventsPublished.WithLabels(eventType, "skipped").Inc(); + } +} diff --git a/Shared/ConduitLLM.Core/Metrics/PromptCachingInjectionMetrics.cs b/Shared/ConduitLLM.Core/Metrics/PromptCachingInjectionMetrics.cs new file mode 100644 index 000000000..ac1e2d598 --- /dev/null +++ b/Shared/ConduitLLM.Core/Metrics/PromptCachingInjectionMetrics.cs @@ -0,0 +1,26 @@ +using Prometheus; + +namespace ConduitLLM.Core.Metrics +{ + /// + /// Prometheus metrics for prompt cache injection operations in the PromptCachingLLMClient decorator. + /// + public static class PromptCachingInjectionMetrics + { + /// + /// Total cache_control injection attempts by model and result (success/error). + /// + public static readonly Counter InjectionsTotal = Prometheus.Metrics + .CreateCounter("conduit_prompt_caching_injections_total", "Total prompt cache injection attempts", + new CounterConfiguration + { + LabelNames = new[] { "model", "result" } // result: success, error + }); + + public static void RecordSuccess(string model) + => InjectionsTotal.WithLabels(model, "success").Inc(); + + public static void RecordError(string model) + => InjectionsTotal.WithLabels(model, "error").Inc(); + } +} diff --git a/Shared/ConduitLLM.Core/Metrics/ProviderInstrumentation.cs b/Shared/ConduitLLM.Core/Metrics/ProviderInstrumentation.cs new file mode 100644 index 000000000..3f6c68603 --- /dev/null +++ b/Shared/ConduitLLM.Core/Metrics/ProviderInstrumentation.cs @@ -0,0 +1,497 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Threading; + +namespace ConduitLLM.Core.Metrics +{ + /// + /// Shared OpenTelemetry instrumentation for LLM provider clients. + /// Provides a single ActivitySource and Meter that all BaseLLMClient + /// implementations emit to, so request count, latency, error rate, token usage, + /// and streaming behavior can be observed uniformly across providers. + /// + public static class ProviderInstrumentation + { + /// + /// Source name used for both the and the . + /// Must be added to OpenTelemetry registration via AddSource / AddMeter. + /// + public const string SourceName = "ConduitLLM.Providers"; + + private const string Version = "1.0.0"; + + /// + /// Activity source for provider request spans. + /// + public static readonly ActivitySource ActivitySource = new(SourceName, Version); + + private static readonly Meter Meter = new(SourceName, Version); + + private static readonly Counter RequestCounter = Meter.CreateCounter( + "provider.requests", + "requests", + "Number of provider API requests"); + + private static readonly Counter ErrorCounter = Meter.CreateCounter( + "provider.errors", + "errors", + "Number of provider API requests that failed"); + + private static readonly Histogram RequestDuration = Meter.CreateHistogram( + "provider.request.duration", + "ms", + "Wall-clock duration of provider API requests"); + + private static readonly Counter PromptTokens = Meter.CreateCounter( + "provider.tokens.prompt", + "tokens", + "Prompt tokens consumed (as reported by the provider)"); + + private static readonly Counter CompletionTokens = Meter.CreateCounter( + "provider.tokens.completion", + "tokens", + "Completion tokens produced (as reported by the provider)"); + + private static readonly Counter TotalTokens = Meter.CreateCounter( + "provider.tokens.total", + "tokens", + "Total tokens reported by the provider (prompt + completion)"); + + private static readonly Counter StreamChunks = Meter.CreateCounter( + "provider.stream.chunks", + "chunks", + "SSE chunks received from streaming provider responses"); + + private static readonly Histogram FirstChunkLatency = Meter.CreateHistogram( + "provider.stream.first_chunk.duration", + "ms", + "Time from streaming request start to first chunk received"); + + private static readonly Histogram PollDuration = Meter.CreateHistogram( + "provider.poll.duration", + "ms", + "Wall-clock duration of async-job poll loops (submit โ†’ terminal state)"); + + private static readonly Histogram PollAttempts = Meter.CreateHistogram( + "provider.poll.attempts", + "attempts", + "Poll attempts executed before an async job reached a terminal state"); + + private static readonly Counter PollTransientErrors = Meter.CreateCounter( + "provider.poll.transient_errors", + "errors", + "Transient errors encountered while polling an async job's status"); + + private static readonly Counter PollOutcomes = Meter.CreateCounter( + "provider.poll.outcomes", + "outcomes", + "Terminal outcomes of async-job poll loops, tagged by outcome"); + + /// + /// Starts a span for a non-streaming provider API request. + /// Returned activity may be null if no listener is subscribed; callers must + /// dispose it (typically via using). + /// + public static Activity? StartRequestActivity( + string operation, + string providerName, + string providerType, + string model) + { + return ActivitySource.StartActivity( + $"provider.{operation}", + ActivityKind.Client, + Activity.Current?.Context ?? default, + new TagList + { + { "provider", providerName }, + { "provider.type", providerType }, + { "provider.model", model }, + { "provider.operation", operation } + }); + } + + /// + /// Records the outcome of a non-streaming provider API request. + /// + public static void RecordRequest( + string operation, + string providerName, + string providerType, + string model, + double durationMs, + bool success, + string? errorType = null) + { + var tags = new TagList + { + { "provider", providerName }, + { "provider.type", providerType }, + { "provider.model", model }, + { "provider.operation", operation }, + { "success", success ? "true" : "false" } + }; + + RequestCounter.Add(1, tags); + RequestDuration.Record(durationMs, tags); + + if (!success) + { + var errorTags = new TagList + { + { "provider", providerName }, + { "provider.type", providerType }, + { "provider.model", model }, + { "provider.operation", operation }, + { "error_type", errorType ?? "Unknown" } + }; + ErrorCounter.Add(1, errorTags); + } + } + + /// + /// Records token usage reported by the provider for a single request or stream. + /// Safe to call with null/zero values โ€” counters are only incremented for + /// dimensions that are actually populated. + /// + public static void RecordUsage( + string operation, + string providerName, + string providerType, + string model, + int? promptTokens, + int? completionTokens, + int? totalTokens) + { + if ((promptTokens ?? 0) <= 0 && (completionTokens ?? 0) <= 0 && (totalTokens ?? 0) <= 0) + { + return; + } + + var tags = new TagList + { + { "provider", providerName }, + { "provider.type", providerType }, + { "provider.model", model }, + { "provider.operation", operation } + }; + + if (promptTokens > 0) + { + PromptTokens.Add(promptTokens.Value, tags); + } + + if (completionTokens > 0) + { + CompletionTokens.Add(completionTokens.Value, tags); + } + + if (totalTokens > 0) + { + TotalTokens.Add(totalTokens.Value, tags); + } + + if (Activity.Current is { } activity) + { + if (promptTokens > 0) activity.SetTag("provider.usage.prompt_tokens", promptTokens.Value); + if (completionTokens > 0) activity.SetTag("provider.usage.completion_tokens", completionTokens.Value); + if (totalTokens > 0) activity.SetTag("provider.usage.total_tokens", totalTokens.Value); + } + } + + /// + /// Begins a polling scope for a long-running async job (submit โ†’ poll โ†’ terminal state). + /// The returned scope tracks attempt count, transient errors, and terminal outcome. + /// Caller owns disposal โ€” typically via using. If the scope is disposed without + /// an explicit failure/timeout/cancelled call, the outcome defaults to succeeded. + /// + public static PollingScope BeginPolling( + string operation, + string providerName, + string providerType, + string model) + { + var activity = ActivitySource.StartActivity( + $"provider.{operation}.poll", + ActivityKind.Internal, + Activity.Current?.Context ?? default, + new TagList + { + { "provider", providerName }, + { "provider.type", providerType }, + { "provider.model", model }, + { "provider.operation", operation } + }); + + return new PollingScope(activity, operation, providerName, providerType, model); + } + + /// + /// Begins a streaming-request scope. Use inside an async iterator with + /// try { ... } finally { scope.Dispose(); }; report each chunk via + /// and call + /// before re-throwing on error. + /// + public static StreamingScope BeginStreaming( + string operation, + string providerName, + string providerType, + string model) + { + var activity = StartRequestActivity(operation, providerName, providerType, model); + return new StreamingScope(activity, operation, providerName, providerType, model); + } + + /// + /// Disposable scope tracking duration, chunk count, first-chunk latency, + /// and outcome of a streaming provider request. + /// + public sealed class StreamingScope : IDisposable + { + private readonly Activity? _activity; + private readonly Stopwatch _stopwatch; + private readonly string _operation; + private readonly string _providerName; + private readonly string _providerType; + private readonly string _model; + private long _chunkCount; + private long _firstChunkMs = -1; + private bool _failed; + private string? _errorType; + private bool _disposed; + + internal StreamingScope( + Activity? activity, + string operation, + string providerName, + string providerType, + string model) + { + _activity = activity; + _operation = operation; + _providerName = providerName; + _providerType = providerType; + _model = model; + _stopwatch = Stopwatch.StartNew(); + } + + /// + /// Reports receipt of a single chunk. Captures first-chunk latency on the first call. + /// + public void RecordChunk() + { + var count = Interlocked.Increment(ref _chunkCount); + if (count == 1) + { + _firstChunkMs = _stopwatch.ElapsedMilliseconds; + + var tags = new TagList + { + { "provider", _providerName }, + { "provider.type", _providerType }, + { "provider.model", _model }, + { "provider.operation", _operation } + }; + FirstChunkLatency.Record(_firstChunkMs, tags); + } + } + + /// + /// Marks the stream as failed. Call this before re-throwing. + /// + public void RecordFailure(string errorType) + { + _failed = true; + _errorType = errorType; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + _stopwatch.Stop(); + var elapsedMs = _stopwatch.Elapsed.TotalMilliseconds; + + RecordRequest(_operation, _providerName, _providerType, _model, elapsedMs, !_failed, _errorType); + + var streamTags = new TagList + { + { "provider", _providerName }, + { "provider.type", _providerType }, + { "provider.model", _model }, + { "provider.operation", _operation } + }; + StreamChunks.Add(_chunkCount, streamTags); + + if (_activity is { } activity) + { + activity.SetTag("provider.stream.chunks", _chunkCount); + if (_firstChunkMs >= 0) + { + activity.SetTag("provider.stream.first_chunk_ms", _firstChunkMs); + } + + if (_failed) + { + activity.SetStatus(ActivityStatusCode.Error, _errorType); + } + else + { + activity.SetStatus(ActivityStatusCode.Ok); + } + + activity.Dispose(); + } + } + } + + /// + /// Terminal outcome of a polling loop. Reported as the outcome tag on + /// provider.poll.* metrics and as a span attribute. + /// + public enum PollOutcome + { + Succeeded, + Failed, + Timeout, + Cancelled, + } + + /// + /// Disposable scope tracking attempt count, transient errors, elapsed time, + /// and terminal outcome of a long-running async-job poll loop. + /// + public sealed class PollingScope : IDisposable + { + private readonly Activity? _activity; + private readonly Stopwatch _stopwatch; + private readonly string _operation; + private readonly string _providerName; + private readonly string _providerType; + private readonly string _model; + private long _attemptCount; + private long _transientErrorCount; + private string? _lastState; + private PollOutcome _outcome = PollOutcome.Succeeded; + private string? _failureType; + private bool _disposed; + + internal PollingScope( + Activity? activity, + string operation, + string providerName, + string providerType, + string model) + { + _activity = activity; + _operation = operation; + _providerName = providerName; + _providerType = providerType; + _model = model; + _stopwatch = Stopwatch.StartNew(); + } + + /// + /// Reports a single poll attempt with the state returned by the classifier. + /// Safe to call with a null state (e.g., when the fetch itself failed). + /// + public void RecordAttempt(string? state) + { + Interlocked.Increment(ref _attemptCount); + if (!string.IsNullOrEmpty(state)) + { + _lastState = state; + } + } + + /// + /// Reports a transient fetch error during polling (counts toward the transient-error budget). + /// + public void RecordTransientError(string errorType) + { + Interlocked.Increment(ref _transientErrorCount); + var tags = new TagList + { + { "provider", _providerName }, + { "provider.type", _providerType }, + { "provider.model", _model }, + { "provider.operation", _operation }, + { "error_type", string.IsNullOrEmpty(errorType) ? "Unknown" : errorType } + }; + PollTransientErrors.Add(1, tags); + } + + /// Marks the poll as terminally failed (classifier returned Failed, or a domain exception was thrown). + public void RecordFailure(string errorType) + { + _outcome = PollOutcome.Failed; + _failureType = string.IsNullOrEmpty(errorType) ? "Unknown" : errorType; + } + + /// Marks the poll as timed out. + public void RecordTimeout() + { + _outcome = PollOutcome.Timeout; + _failureType ??= nameof(PollOutcome.Timeout); + } + + /// Marks the poll as cancelled. + public void RecordCancelled() + { + _outcome = PollOutcome.Cancelled; + _failureType ??= nameof(PollOutcome.Cancelled); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + _stopwatch.Stop(); + var elapsedMs = _stopwatch.Elapsed.TotalMilliseconds; + var outcomeTag = _outcome.ToString(); + + var tags = new TagList + { + { "provider", _providerName }, + { "provider.type", _providerType }, + { "provider.model", _model }, + { "provider.operation", _operation }, + { "outcome", outcomeTag } + }; + + PollDuration.Record(elapsedMs, tags); + PollAttempts.Record(_attemptCount, tags); + PollOutcomes.Add(1, tags); + + if (_activity is { } activity) + { + activity.SetTag("provider.poll.attempts", _attemptCount); + activity.SetTag("provider.poll.transient_errors", _transientErrorCount); + activity.SetTag("provider.poll.outcome", outcomeTag); + if (!string.IsNullOrEmpty(_lastState)) + { + activity.SetTag("provider.poll.last_state", _lastState); + } + if (_outcome == PollOutcome.Succeeded) + { + activity.SetStatus(ActivityStatusCode.Ok); + } + else + { + activity.SetStatus(ActivityStatusCode.Error, _failureType ?? outcomeTag); + } + activity.Dispose(); + } + } + } + } +} diff --git a/Services/ConduitLLM.Gateway/Middleware/CorrelationIdMiddleware.cs b/Shared/ConduitLLM.Core/Middleware/CorrelationIdMiddleware.cs similarity index 88% rename from Services/ConduitLLM.Gateway/Middleware/CorrelationIdMiddleware.cs rename to Shared/ConduitLLM.Core/Middleware/CorrelationIdMiddleware.cs index 572880476..3a22ca2a5 100644 --- a/Services/ConduitLLM.Gateway/Middleware/CorrelationIdMiddleware.cs +++ b/Shared/ConduitLLM.Core/Middleware/CorrelationIdMiddleware.cs @@ -1,10 +1,17 @@ using System.Diagnostics; using ConduitLLM.Core.Extensions; -namespace ConduitLLM.Gateway.Middleware +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Middleware { /// /// Middleware for managing correlation IDs across distributed requests. + /// Extracts correlation IDs from incoming headers (X-Correlation-ID, X-Request-ID, traceparent, etc.) + /// or generates new ones, then propagates them through the request pipeline via logging scopes, + /// OpenTelemetry Activity baggage, and response headers. /// public class CorrelationIdMiddleware { @@ -46,11 +53,11 @@ public CorrelationIdMiddleware( public async Task InvokeAsync(HttpContext context) { var correlationId = GetOrCreateCorrelationId(context); - + // Set correlation ID in various contexts context.TraceIdentifier = correlationId; context.Items[CorrelationIdOptions.CorrelationIdItemsKey] = correlationId; - + // Add to response headers if (_options.IncludeInResponse) { @@ -65,7 +72,7 @@ public async Task InvokeAsync(HttpContext context) } // Set logging scope - using (_logger.BeginScope("{CorrelationId}", correlationId)) + using (_logger.BeginScope(new Dictionary { ["CorrelationId"] = correlationId })) { // Update Activity (for OpenTelemetry integration) var activity = Activity.Current; @@ -76,8 +83,8 @@ public async Task InvokeAsync(HttpContext context) } _logger.LogDebug("Processing request with correlation ID: {CorrelationId}, Path: {Path}", - correlationId, - LoggingSanitizer.S(context.Request.Path.ToString())); + correlationId, + LoggingSanitizer.S(context.Request.Path.ToString())); try { @@ -86,8 +93,8 @@ public async Task InvokeAsync(HttpContext context) finally { _logger.LogDebug("Completed request with correlation ID: {CorrelationId}, Status: {StatusCode}", - correlationId, - context.Response.StatusCode); + correlationId, + context.Response.StatusCode); } } } @@ -103,8 +110,8 @@ private string GetOrCreateCorrelationId(HttpContext context) if (!string.IsNullOrWhiteSpace(correlationId)) { _logger.LogDebug("Using incoming correlation ID from header {HeaderName}: {CorrelationId}", - LoggingSanitizer.S(headerName), - correlationId); + LoggingSanitizer.S(headerName), + correlationId); return correlationId; } } @@ -119,7 +126,7 @@ private string GetOrCreateCorrelationId(HttpContext context) // Use the trace ID portion var traceId = parts[1]; _logger.LogDebug("Using trace ID from traceparent header as correlation ID: {CorrelationId}", - traceId); + traceId); return traceId; } } @@ -130,7 +137,7 @@ private string GetOrCreateCorrelationId(HttpContext context) { var activityTraceId = currentActivity.TraceId.ToString(); _logger.LogDebug("Using Activity trace ID as correlation ID: {CorrelationId}", - activityTraceId); + activityTraceId); return activityTraceId; } @@ -143,8 +150,8 @@ private string GetOrCreateCorrelationId(HttpContext context) private string GenerateCorrelationId() { - return _options.UseShortIds - ? Guid.NewGuid().ToString("N").Substring(0, 8) + return _options.UseShortIds + ? Guid.NewGuid().ToString("N")[..8] : Guid.NewGuid().ToString(); } } @@ -217,4 +224,4 @@ public static IApplicationBuilder UseCorrelationId( return context.TraceIdentifier; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Core/Middleware/CountingStream.cs b/Shared/ConduitLLM.Core/Middleware/CountingStream.cs new file mode 100644 index 000000000..c57a3c25a --- /dev/null +++ b/Shared/ConduitLLM.Core/Middleware/CountingStream.cs @@ -0,0 +1,64 @@ +namespace ConduitLLM.Core.Middleware +{ + /// + /// A pass-through stream wrapper that counts bytes written without buffering. + /// Used by HTTP metrics middleware to measure response size without the memory + /// overhead of copying the entire response into a MemoryStream. + /// Critical for streaming responses (SSE chat completions) where buffering + /// would prevent chunks from being sent incrementally. + /// + public sealed class CountingStream : Stream + { + private readonly Stream _inner; + + public CountingStream(Stream inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + /// + /// Gets the total number of bytes written to the stream. + /// + public long BytesWritten { get; private set; } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => _inner.CanWrite; + public override long Length => _inner.Length; + public override long Position + { + get => _inner.Position; + set => _inner.Position = value; + } + + public override void Flush() => _inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inner.ReadAsync(buffer, offset, count, cancellationToken); + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); + public override void SetLength(long value) => _inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + { + _inner.Write(buffer, offset, count); + BytesWritten += count; + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await _inner.WriteAsync(buffer, offset, count, cancellationToken); + BytesWritten += count; + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await _inner.WriteAsync(buffer, cancellationToken); + BytesWritten += buffer.Length; + } + } +} diff --git a/Shared/ConduitLLM.Core/Middleware/EphemeralKeyCleanupMiddlewareBase.cs b/Shared/ConduitLLM.Core/Middleware/EphemeralKeyCleanupMiddlewareBase.cs new file mode 100644 index 000000000..960e40c37 --- /dev/null +++ b/Shared/ConduitLLM.Core/Middleware/EphemeralKeyCleanupMiddlewareBase.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Middleware +{ + /// + /// Base middleware for cleaning up ephemeral keys after request completion. + /// Subclasses define the context item keys and provide the deletion service via method injection. + /// + public abstract class EphemeralKeyCleanupMiddlewareBase + { + private readonly RequestDelegate _next; + protected readonly ILogger Logger; + + /// + /// The HttpContext.Items key that flags whether the key should be deleted (expects bool value). + /// + protected abstract string DeleteFlagKey { get; } + + /// + /// The HttpContext.Items key that stores the ephemeral key string to delete. + /// + protected abstract string KeyStorageKey { get; } + + protected EphemeralKeyCleanupMiddlewareBase(RequestDelegate next, ILogger logger) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Executes the request pipeline and cleans up the ephemeral key afterward using the provided deletion function. + /// + protected async Task InvokeAsync(HttpContext context, Func deleteKeyAsync) + { + try + { + await _next(context); + } + finally + { + await CleanupKeyIfNeededAsync(context, deleteKeyAsync); + } + } + + private async Task CleanupKeyIfNeededAsync(HttpContext context, Func deleteKeyAsync) + { + if (context.Items.TryGetValue(DeleteFlagKey, out var shouldDelete) && + shouldDelete is bool delete && delete && + context.Items.TryGetValue(KeyStorageKey, out var keyObj) && + keyObj is string ephemeralKey) + { + try + { + await deleteKeyAsync(ephemeralKey); + Logger.LogDebug("Cleaned up ephemeral key after request completion"); + } + catch (Exception ex) + { + // Best effort - don't let cleanup failures affect the response + Logger.LogWarning(ex, "Failed to clean up ephemeral key after request"); + } + } + } + } +} diff --git a/Shared/ConduitLLM.Core/Middleware/ExceptionHandlingMiddlewareBase.cs b/Shared/ConduitLLM.Core/Middleware/ExceptionHandlingMiddlewareBase.cs new file mode 100644 index 000000000..4b4a9f589 --- /dev/null +++ b/Shared/ConduitLLM.Core/Middleware/ExceptionHandlingMiddlewareBase.cs @@ -0,0 +1,147 @@ +using System.Text.Json; + +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Extensions; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Middleware; + +/// +/// Base middleware for global exception handling. Catches unhandled exceptions, +/// logs them with request context, maps them via , +/// and writes a JSON error response. +/// Subclasses control the response format (e.g., ErrorResponseDto vs OpenAIErrorResponse). +/// +public abstract class ExceptionHandlingMiddlewareBase +{ + private readonly RequestDelegate _next; + private readonly IWebHostEnvironment _environment; + protected readonly ILogger Logger; + + protected static readonly JsonSerializerOptions ErrorJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + /// + /// Display name used in log messages (e.g., "AdminExceptionMiddleware"). + /// + protected abstract string MiddlewareName { get; } + + protected ExceptionHandlingMiddlewareBase( + RequestDelegate next, + ILogger logger, + IWebHostEnvironment environment) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _environment = environment ?? throw new ArgumentNullException(nameof(environment)); + } + + /// + /// Invokes the middleware. + /// + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var traceId = context.TraceIdentifier; + + // Capture request body for debugging mutations + string? requestBody = null; + try + { + requestBody = await RequestBodyCapture.CaptureAsync(context); + } + catch + { + // Body capture should never prevent error handling + } + + // Log with or without body + if (requestBody != null) + { + Logger.LogError(exception, + "Unhandled exception caught by {MiddlewareName}. TraceId: {TraceId}, Method: {Method}, Path: {Path}, RequestBody: {RequestBody}", + MiddlewareName, traceId, + LoggingSanitizer.S(context.Request.Method), + LoggingSanitizer.S(context.Request.Path.ToString()), + requestBody); + } + else + { + Logger.LogError(exception, + "Unhandled exception caught by {MiddlewareName}. TraceId: {TraceId}, Method: {Method}, Path: {Path}", + MiddlewareName, traceId, + LoggingSanitizer.S(context.Request.Method), + LoggingSanitizer.S(context.Request.Path.ToString())); + } + + // Map exception using the shared mapper + var mapping = ExceptionToResponseMapper.Map(exception); + + // In development, show actual exception messages for redacted responses + var message = mapping.IncludeExceptionMessageInLog + ? mapping.ResponseMessage + : (_environment.IsDevelopment() ? exception.Message : mapping.ResponseMessage); + + // Hook for subclass-specific behavior (metrics, security logging) + await OnExceptionMappedAsync(context, exception, mapping); + + // Don't try to write if the response has already started + if (context.Response.HasStarted) + { + Logger.LogWarning( + "Response has already started, cannot write error response for TraceId: {TraceId}", + traceId); + return; + } + + // Set common response headers + context.Response.StatusCode = mapping.StatusCode; + context.Response.ContentType = "application/json"; + context.Response.Headers["X-Request-Id"] = traceId; + + if (exception is RateLimitExceededException rateLimitEx && rateLimitEx.RetryAfterSeconds.HasValue) + { + context.Response.Headers["Retry-After"] = rateLimitEx.RetryAfterSeconds.Value.ToString(); + } + + // Serialize and write the format-specific response + var json = CreateErrorResponseJson(message, mapping); + await context.Response.WriteAsync(json); + } + + /// + /// Called after the exception is mapped but before the response is written. + /// Override to add metrics, security logging, etc. + /// + protected virtual Task OnExceptionMappedAsync( + HttpContext context, + Exception exception, + ExceptionToResponseMapper.ExceptionMappingResult mapping) + => Task.CompletedTask; + + /// + /// Creates the JSON response body for the error. Subclasses produce their format + /// (e.g., ErrorResponseDto or OpenAIErrorResponse). + /// + protected abstract string CreateErrorResponseJson( + string message, + ExceptionToResponseMapper.ExceptionMappingResult mapping); +} diff --git a/Shared/ConduitLLM.Core/Middleware/GlobalExceptionMiddleware.cs b/Shared/ConduitLLM.Core/Middleware/GlobalExceptionMiddleware.cs deleted file mode 100644 index b6d4473d3..000000000 --- a/Shared/ConduitLLM.Core/Middleware/GlobalExceptionMiddleware.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System.Net; -using System.Text.Json; - -using ConduitLLM.Core.Extensions; -using ConduitLLM.Core.Interfaces; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Core.Middleware -{ - /// - /// Global exception handling middleware that provides consistent error responses - /// and prevents sensitive information disclosure. - /// - public class GlobalExceptionMiddleware - { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly IWebHostEnvironment _environment; - private readonly ISecurityEventLogger? _securityEventLogger; - - /// - /// Initializes a new instance of the class. - /// - /// The next middleware in the pipeline. - /// The logger. - /// The web host environment. - /// Optional security event logger. - public GlobalExceptionMiddleware( - RequestDelegate next, - ILogger logger, - IWebHostEnvironment environment, - ISecurityEventLogger? securityEventLogger = null) - { - _next = next ?? throw new ArgumentNullException(nameof(next)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _environment = environment ?? throw new ArgumentNullException(nameof(environment)); - _securityEventLogger = securityEventLogger; - } - - /// - /// Invokes the middleware. - /// - /// The HTTP context. - public async Task InvokeAsync(HttpContext context) - { - try - { - await _next(context); - } - catch (Exception ex) - { - await HandleExceptionAsync(context, ex); - } - } - - private async Task HandleExceptionAsync(HttpContext context, Exception exception) - { - // Log the exception with full details - var traceId = context.TraceIdentifier; - _logger.LogError(exception, - "Unhandled exception occurred {TraceId} {Method} {Path}", - traceId, - LoggingSanitizer.S(context.Request.Method), - LoggingSanitizer.S(context.Request.Path.ToString())); - - // Determine the response based on exception type - var (statusCode, errorResponse) = GetErrorResponse(exception, traceId); - - // Log security-relevant exceptions - await LogSecurityExceptionAsync(context, exception, statusCode); - - // Write the response - context.Response.StatusCode = statusCode; - context.Response.ContentType = "application/json"; - - var jsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; - - var json = JsonSerializer.Serialize(errorResponse, jsonOptions); - await context.Response.WriteAsync(json); - } - - private (int statusCode, ErrorResponse response) GetErrorResponse(Exception exception, string traceId) - { - var errorResponse = new ErrorResponse - { - TraceId = traceId, - Timestamp = DateTime.UtcNow, - Path = null // Will be set later if needed - }; - - // Handle specific exception types - switch (exception) - { - case UnauthorizedAccessException _: - errorResponse.Error = "Unauthorized"; - errorResponse.Message = "Access denied. Please check your credentials."; - return ((int)HttpStatusCode.Unauthorized, errorResponse); - - case ArgumentNullException _: - errorResponse.Error = "Bad Request"; - errorResponse.Message = "Required parameter is missing."; - if (_environment.IsDevelopment()) - { - errorResponse.Details = exception.Message; - } - return ((int)HttpStatusCode.BadRequest, errorResponse); - - case ArgumentException _: - errorResponse.Error = "Bad Request"; - errorResponse.Message = "Invalid request parameters."; - if (_environment.IsDevelopment()) - { - errorResponse.Details = exception.Message; - } - return ((int)HttpStatusCode.BadRequest, errorResponse); - - case InvalidOperationException _: - errorResponse.Error = "Invalid Operation"; - errorResponse.Message = "The requested operation is not valid."; - if (_environment.IsDevelopment()) - { - errorResponse.Details = exception.Message; - } - return ((int)HttpStatusCode.BadRequest, errorResponse); - - case TimeoutException _: - errorResponse.Error = "Request Timeout"; - errorResponse.Message = "The request timed out. Please try again."; - return ((int)HttpStatusCode.RequestTimeout, errorResponse); - - case NotImplementedException _: - errorResponse.Error = "Not Implemented"; - errorResponse.Message = "This feature is not yet available."; - return ((int)HttpStatusCode.NotImplemented, errorResponse); - - case KeyNotFoundException _: - errorResponse.Error = "Not Found"; - errorResponse.Message = "The requested resource was not found."; - return ((int)HttpStatusCode.NotFound, errorResponse); - - default: - // Generic error response for production - errorResponse.Error = "Internal Server Error"; - errorResponse.Message = "An unexpected error occurred. Please try again later."; - - // Only include details in development - if (_environment.IsDevelopment()) - { - errorResponse.Details = exception.ToString(); - } - - return ((int)HttpStatusCode.InternalServerError, errorResponse); - } - } - - private async Task LogSecurityExceptionAsync(HttpContext context, Exception exception, int statusCode) - { - if (_securityEventLogger == null) - return; - - // Log certain exceptions as security events - if (exception is UnauthorizedAccessException) - { - var virtualKey = context.Request.Headers["X-Virtual-Key"].FirstOrDefault() ?? "Unknown"; - var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "Unknown"; - - await _securityEventLogger.LogAuthorizationViolationAsync( - virtualKey, - context.Request.Path, - context.Request.Method, - ipAddress); - } - else if (statusCode == (int)HttpStatusCode.BadRequest && - (exception is ArgumentException || exception is FormatException)) - { - // Potential injection attempt or malformed input - var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "Unknown"; - await _securityEventLogger.LogSuspiciousActivityAsync( - $"Malformed input detected: {exception.GetType().Name}", - SecurityEventSeverity.Low, - new Dictionary - { - ["path"] = context.Request.Path.ToString(), - ["method"] = context.Request.Method, - ["ipAddress"] = ipAddress - }); - } - } - } - - /// - /// Standardized error response format. - /// - public class ErrorResponse - { - /// - /// Gets or sets the trace ID for correlation. - /// - public string TraceId { get; set; } = string.Empty; - - /// - /// Gets or sets the error type. - /// - public string Error { get; set; } = string.Empty; - - /// - /// Gets or sets the user-friendly error message. - /// - public string Message { get; set; } = string.Empty; - - /// - /// Gets or sets additional details (only in development). - /// - public string? Details { get; set; } - - /// - /// Gets or sets the timestamp. - /// - public DateTime Timestamp { get; set; } - - /// - /// Gets or sets the request path (optional). - /// - public string? Path { get; set; } - - /// - /// Gets or sets validation errors (for bad requests). - /// - public Dictionary? ValidationErrors { get; set; } - } - - /// - /// Extension methods for global exception middleware. - /// - public static class GlobalExceptionMiddlewareExtensions - { - /// - /// Adds the global exception handling middleware to the pipeline. - /// - /// The application builder. - /// The application builder. - public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Middleware/HttpMetricsMiddlewareBase.cs b/Shared/ConduitLLM.Core/Middleware/HttpMetricsMiddlewareBase.cs new file mode 100644 index 000000000..ce2ee7e5f --- /dev/null +++ b/Shared/ConduitLLM.Core/Middleware/HttpMetricsMiddlewareBase.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Middleware +{ + /// + /// Base class for HTTP metrics middleware that provides the common request processing + /// pipeline. Subclasses own Prometheus metric definitions and recording (metrics are + /// static and names must differ between services). + /// + public abstract class HttpMetricsMiddlewareBase + { + private readonly RequestDelegate _next; + protected readonly ILogger Logger; + + protected HttpMetricsMiddlewareBase(RequestDelegate next, ILogger logger) + { + _next = next; + Logger = logger; + } + + /// + /// Returns a normalized path for metric labels to reduce cardinality. + /// + protected abstract string GetNormalizedPath(HttpContext context); + + /// + /// Returns true if metrics should be skipped for this request (e.g., health checks). + /// + protected abstract bool ShouldSkipMetrics(HttpContext context); + + /// + /// Increments the active request gauge. + /// + protected abstract void IncrementActiveRequests(string method, string path); + + /// + /// Decrements the active request gauge. + /// + protected abstract void DecrementActiveRequests(string method, string path); + + /// + /// Records the request size metric. + /// + protected abstract void RecordRequestSize(string method, string path, long bytes); + + /// + /// Records response metrics (request count, duration, response size, etc.). + /// + protected abstract void RecordResponseMetrics( + string method, string path, int statusCode, double durationSeconds, + long responseBytes, HttpContext context); + + /// + /// Records an error metric. + /// + protected abstract void RecordError(string method, string path, int statusCode, string errorType); + + /// + /// Called when an unhandled exception occurs. Override to adjust status code (e.g., 200 โ†’ 500). + /// + protected virtual void OnException(HttpContext context) { } + + /// + /// Threshold in seconds for slow request warnings. 0 disables. + /// + protected virtual double SlowRequestWarningThresholdSeconds => 5.0; + + public async Task InvokeAsync(HttpContext context) + { + if (ShouldSkipMetrics(context)) + { + await _next(context); + return; + } + + var path = GetNormalizedPath(context); + var method = context.Request.Method; + + if (context.Request.ContentLength.HasValue) + { + RecordRequestSize(method, path, context.Request.ContentLength.Value); + } + + var stopwatch = Stopwatch.StartNew(); + IncrementActiveRequests(method, path); + + var originalBodyStream = context.Response.Body; + using var countingStream = new CountingStream(originalBodyStream); + context.Response.Body = countingStream; + + try + { + await _next(context); + } + catch (TaskCanceledException) + { + context.Response.StatusCode = 499; // Client closed request + RecordError(method, path, 499, "client_cancelled"); + throw; + } + catch (Exception ex) + { + var errorType = ex.GetType().Name; + RecordError(method, path, context.Response.StatusCode, errorType); + Logger.LogError(ex, "Unhandled exception in request pipeline"); + OnException(context); + throw; + } + finally + { + context.Response.Body = originalBodyStream; + DecrementActiveRequests(method, path); + stopwatch.Stop(); + + RecordResponseMetrics( + method, path, context.Response.StatusCode, + stopwatch.Elapsed.TotalSeconds, countingStream.BytesWritten, context); + + if (SlowRequestWarningThresholdSeconds > 0 && stopwatch.Elapsed.TotalSeconds > SlowRequestWarningThresholdSeconds) + { + Logger.LogWarning( + "Slow request detected: {Method} {Path} took {Duration:F2}s with status {StatusCode}", + method, path, stopwatch.Elapsed.TotalSeconds, context.Response.StatusCode.ToString()); + } + } + } + } +} diff --git a/Shared/ConduitLLM.Core/Middleware/OpenAIErrorMiddleware.cs b/Shared/ConduitLLM.Core/Middleware/OpenAIErrorMiddleware.cs index 706459c1a..d3613ce91 100644 --- a/Shared/ConduitLLM.Core/Middleware/OpenAIErrorMiddleware.cs +++ b/Shared/ConduitLLM.Core/Middleware/OpenAIErrorMiddleware.cs @@ -1,28 +1,35 @@ using System.Text.Json; using ConduitLLM.Core.Exceptions; -using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Prometheus; + namespace ConduitLLM.Core.Middleware { /// /// Middleware that maps exceptions to OpenAI-compatible error responses with proper HTTP status codes. + /// Uses as the single source of truth for exception mapping. /// - public class OpenAIErrorMiddleware + public class OpenAIErrorMiddleware : ExceptionHandlingMiddlewareBase { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly IWebHostEnvironment _environment; private readonly ISecurityEventLogger? _securityEventLogger; + private static readonly Counter ExceptionsHandled = Prometheus.Metrics + .CreateCounter("conduit_error_middleware_exceptions_total", "Total exceptions handled by error middleware", + new CounterConfiguration + { + LabelNames = new[] { "exception_type", "status_code", "endpoint" } + }); + + protected override string MiddlewareName => "OpenAIErrorMiddleware"; + /// /// Initializes a new instance of the class. /// @@ -35,279 +42,67 @@ public OpenAIErrorMiddleware( ILogger logger, IWebHostEnvironment environment, ISecurityEventLogger? securityEventLogger = null) + : base(next, logger, environment) { - _next = next ?? throw new ArgumentNullException(nameof(next)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _environment = environment ?? throw new ArgumentNullException(nameof(environment)); _securityEventLogger = securityEventLogger; } - /// - /// Invokes the middleware. - /// - /// The HTTP context. - public async Task InvokeAsync(HttpContext context) - { - try - { - await _next(context); - } - catch (Exception ex) - { - await HandleExceptionAsync(context, ex); - } - } - - private async Task HandleExceptionAsync(HttpContext context, Exception exception) + /// + protected override async Task OnExceptionMappedAsync( + HttpContext context, + Exception exception, + ExceptionToResponseMapper.ExceptionMappingResult mapping) { - // Log the exception with full details - var traceId = context.TraceIdentifier; - _logger.LogError(exception, - "Exception handled by OpenAIErrorMiddleware {TraceId} {Method} {Path}", - traceId, - LoggingSanitizer.S(context.Request.Method), - LoggingSanitizer.S(context.Request.Path.ToString())); - - // Map exception to OpenAI error response - var (statusCode, errorResponse) = MapExceptionToResponse(exception, traceId); + // Record exception metrics + var normalizedEndpoint = NormalizeEndpointForMetrics(context.Request.Path.Value ?? "/"); + ExceptionsHandled.WithLabels( + exception.GetType().Name, + mapping.StatusCode.ToString(), + normalizedEndpoint).Inc(); // Log security-relevant exceptions - await LogSecurityExceptionAsync(context, exception, statusCode); - - // Set response headers - context.Response.StatusCode = statusCode; - context.Response.ContentType = "application/json"; - - // Add correlation ID header - context.Response.Headers["X-Request-Id"] = traceId; - - // Add Retry-After header for rate limit exceptions - if (exception is RateLimitExceededException rateLimitEx && rateLimitEx.RetryAfterSeconds.HasValue) - { - context.Response.Headers["Retry-After"] = rateLimitEx.RetryAfterSeconds.Value.ToString(); - } + await LogSecurityExceptionAsync(context, exception, mapping.StatusCode); + } - // Serialize and write response - var jsonOptions = new JsonSerializerOptions + /// + protected override string CreateErrorResponseJson( + string message, + ExceptionToResponseMapper.ExceptionMappingResult mapping) + { + var errorResponse = new OpenAIErrorResponse { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false + Error = new OpenAIError + { + Message = message, + Type = mapping.OpenAIErrorType, + Code = mapping.ErrorCode, + Param = mapping.Param + } }; - var json = JsonSerializer.Serialize(errorResponse, jsonOptions); - await context.Response.WriteAsync(json); + return JsonSerializer.Serialize(errorResponse, ErrorJsonOptions); } - private (int statusCode, OpenAIErrorResponse response) MapExceptionToResponse(Exception exception, string traceId) + private static string NormalizeEndpointForMetrics(string path) { - switch (exception) - { - case ModelNotFoundException modelEx: - return (404, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = modelEx.Message, - Type = "invalid_request_error", - Code = "model_not_found", - Param = "model" - } - }); - - case InvalidRequestException invalidEx: - return (400, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = invalidEx.Message, - Type = "invalid_request_error", - Code = invalidEx.ErrorCode ?? "invalid_request", - Param = invalidEx.Param - } - }); - - case AuthorizationException authEx: - return (403, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = authEx.Message, - Type = "invalid_request_error", - Code = "authorization_required" - } - }); - - case RequestTimeoutException timeoutEx: - return (408, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = timeoutEx.Message, - Type = "timeout_error", - Code = "request_timeout" - } - }); - - case PayloadTooLargeException payloadEx: - return (413, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = payloadEx.Message, - Type = "invalid_request_error", - Code = "payload_too_large" - } - }); - - case RateLimitExceededException rateLimitEx: - return (429, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = rateLimitEx.Message, - Type = "rate_limit_error", - Code = "rate_limit_exceeded" - } - }); - - case ServiceUnavailableException serviceEx: - return (503, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = serviceEx.Message, - Type = "service_unavailable", - Code = "service_unavailable" - } - }); - - case ConfigurationException configEx: - // Legacy ConfigurationException support - map to 500 - return (500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = _environment.IsDevelopment() ? configEx.Message : "Configuration error occurred", - Type = "server_error", - Code = "configuration_error" - } - }); - - case LLMCommunicationException commEx: - // Map based on status code if available - if (commEx.StatusCode.HasValue) - { - var statusCode = (int)commEx.StatusCode.Value; - return (statusCode, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = commEx.Message, - Type = statusCode >= 500 ? "server_error" : "invalid_request_error", - Code = "provider_communication_error" - } - }); - } - goto default; - - case UnauthorizedAccessException _: - return (401, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Authentication required", - Type = "invalid_request_error", - Code = "unauthorized" - } - }); - - case ArgumentNullException argNullEx: - return (400, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = _environment.IsDevelopment() ? argNullEx.Message : "Required parameter is missing", - Type = "invalid_request_error", - Code = "missing_parameter", - Param = argNullEx.ParamName - } - }); - - case ArgumentException argEx: - return (400, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = _environment.IsDevelopment() ? argEx.Message : "Invalid parameter value", - Type = "invalid_request_error", - Code = "invalid_parameter", - Param = argEx.ParamName - } - }); - - case InvalidOperationException invalidOpEx: - return (400, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = _environment.IsDevelopment() ? invalidOpEx.Message : "Invalid operation", - Type = "invalid_request_error", - Code = "invalid_operation" - } - }); - - case TimeoutException _: - return (408, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Request timed out", - Type = "timeout_error", - Code = "timeout" - } - }); - - case NotImplementedException _: - return (501, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Feature not implemented", - Type = "server_error", - Code = "not_implemented" - } - }); - - case KeyNotFoundException _: - return (404, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = "Resource not found", - Type = "invalid_request_error", - Code = "not_found" - } - }); - - default: - // TODO: Future - Add circuit breaker status tracking here - // TODO: Future - Emit health monitoring events for 5xx errors - - // Log unexpected exceptions at ERROR level - _logger.LogError(exception, "Unexpected exception: {TraceId}", traceId); - - return (500, new OpenAIErrorResponse - { - Error = new OpenAIError - { - Message = _environment.IsDevelopment() - ? exception.Message - : "An unexpected error occurred", - Type = "server_error", - Code = "internal_error" - } - }); - } + // Reduce cardinality by normalizing to known endpoint patterns + if (path.StartsWith("/v1/chat/completions", StringComparison.OrdinalIgnoreCase)) + return "/v1/chat/completions"; + if (path.StartsWith("/v1/embeddings", StringComparison.OrdinalIgnoreCase)) + return "/v1/embeddings"; + if (path.StartsWith("/v1/images", StringComparison.OrdinalIgnoreCase)) + return "/v1/images"; + if (path.StartsWith("/v1/videos", StringComparison.OrdinalIgnoreCase)) + return "/v1/videos"; + if (path.StartsWith("/v1/models", StringComparison.OrdinalIgnoreCase)) + return "/v1/models"; + if (path.StartsWith("/v1/batch", StringComparison.OrdinalIgnoreCase)) + return "/v1/batch"; + if (path.StartsWith("/v1/audio", StringComparison.OrdinalIgnoreCase)) + return "/v1/audio"; + if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) + return "/api/*"; + return "/other"; } private async Task LogSecurityExceptionAsync(HttpContext context, Exception exception, int statusCode) @@ -320,14 +115,14 @@ private async Task LogSecurityExceptionAsync(HttpContext context, Exception exce { var virtualKey = context.Request.Headers["X-Virtual-Key"].FirstOrDefault() ?? "Unknown"; var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "Unknown"; - + await _securityEventLogger.LogAuthorizationViolationAsync( virtualKey, context.Request.Path, context.Request.Method, ipAddress); } - else if (statusCode == 400 && + else if (statusCode == 400 && (exception is ArgumentException || exception is InvalidRequestException)) { // Potential injection attempt or malformed input @@ -361,4 +156,4 @@ public static IApplicationBuilder UseOpenAIErrorHandling(this IApplicationBuilde return builder.UseMiddleware(); } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Core/Middleware/RequestTrackingMiddlewareBase.cs b/Shared/ConduitLLM.Core/Middleware/RequestTrackingMiddlewareBase.cs new file mode 100644 index 000000000..a37d609e0 --- /dev/null +++ b/Shared/ConduitLLM.Core/Middleware/RequestTrackingMiddlewareBase.cs @@ -0,0 +1,115 @@ +using System.Diagnostics; +using ConduitLLM.Core.Extensions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Middleware +{ + /// + /// Base class for request tracking middleware that provides structured logging + /// for API requests. Subclasses customize behavior for Gateway vs Admin APIs. + /// + public abstract class RequestTrackingMiddlewareBase + { + private readonly RequestDelegate _next; + protected readonly ILogger Logger; + + protected RequestTrackingMiddlewareBase(RequestDelegate next, ILogger logger) + { + _next = next; + Logger = logger; + } + + /// + /// The service name used in log messages (e.g., "Gateway API" or "Admin API"). + /// + protected abstract string ServiceName { get; } + + /// + /// Returns true if the request should be skipped entirely (e.g., health checks). + /// + protected virtual bool ShouldSkipRequest(HttpContext context) => false; + + /// + /// Returns a request identifier for logging (e.g., VirtualKeyId). Null if none. + /// + protected virtual string? GetRequestIdentifier(HttpContext context) => null; + + /// + /// Called before the request is processed. Override to log request start. + /// + protected virtual void OnBeforeRequest(HttpContext context, string method, string path) { } + + /// + /// Threshold in milliseconds for slow request warnings. 0 disables. + /// + protected virtual int SlowRequestWarningThresholdMs => 0; + + /// + /// Returns true if the HTTP method is a mutation (POST, PUT, PATCH, DELETE). + /// + protected static bool IsMutationMethod(string method) + { + return method is "POST" or "PUT" or "PATCH" or "DELETE"; + } + + public async Task InvokeAsync(HttpContext context) + { + if (ShouldSkipRequest(context)) + { + await _next(context); + return; + } + + var stopwatch = Stopwatch.StartNew(); + var requestPath = context.Request.Path; + var requestMethod = context.Request.Method; + var isMutation = IsMutationMethod(requestMethod); + var sanitizedPath = LoggingSanitizer.S(requestPath.ToString()) ?? string.Empty; + + OnBeforeRequest(context, requestMethod, sanitizedPath); + + try + { + await _next(context); + + stopwatch.Stop(); + var elapsedMs = stopwatch.ElapsedMilliseconds; + var identifier = GetRequestIdentifier(context); + var identifierSuffix = identifier != null ? $" [VirtualKey: {identifier}]" : ""; + + if (SlowRequestWarningThresholdMs > 0 && elapsedMs > SlowRequestWarningThresholdMs) + { + Logger.LogWarning( + "Slow {ServiceName} request: {Method} {Path} took {ElapsedMs}ms with status {StatusCode}{Identifier}", + ServiceName, requestMethod, sanitizedPath, elapsedMs, context.Response.StatusCode, identifierSuffix); + } + else if (isMutation || elapsedMs > 1000) + { + Logger.LogInformation( + "{ServiceName} Request: {Method} {Path} completed with status {StatusCode} in {ElapsedMs}ms{Identifier}", + ServiceName, requestMethod, sanitizedPath, context.Response.StatusCode, elapsedMs, identifierSuffix); + } + else + { + Logger.LogDebug( + "{ServiceName} Request: {Method} {Path} completed with status {StatusCode} in {ElapsedMs}ms{Identifier}", + ServiceName, requestMethod, sanitizedPath, context.Response.StatusCode, elapsedMs, identifierSuffix); + } + } + catch (Exception ex) + { + stopwatch.Stop(); + var identifier = GetRequestIdentifier(context); + var identifierSuffix = identifier != null ? $" [VirtualKey: {identifier}]" : ""; + + Logger.LogError( + ex, + "{ServiceName} Request: {Method} {Path} failed after {ElapsedMs}ms{Identifier}", + ServiceName, requestMethod, sanitizedPath, stopwatch.ElapsedMilliseconds, identifierSuffix); + + throw; + } + } + } +} diff --git a/Shared/ConduitLLM.Core/Models/CacheRegion.cs b/Shared/ConduitLLM.Core/Models/CacheRegion.cs index be967a7d6..0effdebaa 100644 --- a/Shared/ConduitLLM.Core/Models/CacheRegion.cs +++ b/Shared/ConduitLLM.Core/Models/CacheRegion.cs @@ -112,6 +112,12 @@ public enum CacheRegion /// Monitoring, + /// + /// Parsed pricing rules configurations for model cost billing. + /// Reduces JSON parsing overhead by caching deserialized PricingRulesConfig objects. + /// + PricingRules, + /// /// Default region for unspecified cache operations. /// Should be avoided in favor of specific regions. diff --git a/Shared/ConduitLLM.Core/Models/CachedProviderCredential.cs b/Shared/ConduitLLM.Core/Models/CachedProviderCredential.cs index 617ff740f..e37f8220b 100644 --- a/Shared/ConduitLLM.Core/Models/CachedProviderCredential.cs +++ b/Shared/ConduitLLM.Core/Models/CachedProviderCredential.cs @@ -30,7 +30,7 @@ public class CachedProvider /// /// Checks if the provider has any enabled keys /// - public bool HasEnabledKeys => EnabledKeys.Count() > 0; + public bool HasEnabledKeys => EnabledKeys.Any(); /// /// Gets the effective API key (primary key or fallback to legacy) diff --git a/Shared/ConduitLLM.Core/Models/ContentParts.cs b/Shared/ConduitLLM.Core/Models/ContentParts.cs index 24a8f9979..0e36dc660 100644 --- a/Shared/ConduitLLM.Core/Models/ContentParts.cs +++ b/Shared/ConduitLLM.Core/Models/ContentParts.cs @@ -117,23 +117,65 @@ public static async Task FromFilePathAsync(string filePath, string? de } /// - /// Creates an ImageUrl by downloading an image from an external URL and converting it to a base64 data URL + /// Creates an ImageUrl by downloading an image from an external URL and converting it to a base64 data URL. /// /// The HTTP URL of the image + /// The HttpClient instance to use for downloading (should be from IHttpClientFactory) /// Optional detail level for vision models + /// A token to monitor for cancellation requests /// An ImageUrl object with the image as a base64 data URL - public static async Task FromExternalUrlAsync(string url, string? detail = null) + public static async Task FromExternalUrlAsync(string url, HttpClient httpClient, string? detail = null, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(url)) throw new ArgumentException("URL cannot be null or empty", nameof(url)); + if (httpClient == null) + throw new ArgumentNullException(nameof(httpClient)); + if (url.StartsWith("data:")) return new ImageUrl { Url = url, Detail = detail }; - using var httpClient = new HttpClient(); - byte[] imageBytes = await httpClient.GetByteArrayAsync(url); + byte[] imageBytes = await httpClient.GetByteArrayAsync(url, cancellationToken); // Try to determine MIME type from content or fall back to a default + string mimeType = DetectMimeTypeFromBytes(imageBytes); + + string dataUrl = $"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}"; + + return new ImageUrl + { + Url = dataUrl, + Detail = detail + }; + } + + /// + /// Creates an ImageUrl by downloading an image from an external URL and converting it to a base64 data URL. + /// + /// The HTTP URL of the image + /// Optional detail level for vision models + /// An ImageUrl object with the image as a base64 data URL + /// + /// This method is no longer supported. Use the overload that accepts an HttpClient from IHttpClientFactory, + /// or use IImageDownloadService to properly manage HTTP connections and avoid socket exhaustion. + /// + /// Always thrown. Use the overload that accepts an HttpClient parameter. + [Obsolete("Use the overload that accepts an HttpClient from IHttpClientFactory, or use IImageDownloadService. This method is no longer supported.", error: true)] + public static Task FromExternalUrlAsync(string url, string? detail = null) + { + throw new NotSupportedException( + "This method is no longer supported due to socket exhaustion risks. " + + "Use FromExternalUrlAsync(url, httpClient, detail, cancellationToken) with an HttpClient from IHttpClientFactory, " + + "or use IImageDownloadService."); + } + + /// + /// Detects the MIME type from image bytes by examining magic numbers. + /// + /// The image data bytes. + /// The detected MIME type, or "image/jpeg" as fallback. + private static string DetectMimeTypeFromBytes(byte[] imageBytes) + { string mimeType = "image/jpeg"; // Default fallback // Check magic numbers for common image formats @@ -154,13 +196,7 @@ public static async Task FromExternalUrlAsync(string url, string? deta mimeType = "image/bmp"; } - string dataUrl = $"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}"; - - return new ImageUrl - { - Url = dataUrl, - Detail = detail - }; + return mimeType; } /// diff --git a/Shared/ConduitLLM.Core/Models/EphemeralKeyDataBase.cs b/Shared/ConduitLLM.Core/Models/EphemeralKeyDataBase.cs new file mode 100644 index 000000000..7c8f8d95a --- /dev/null +++ b/Shared/ConduitLLM.Core/Models/EphemeralKeyDataBase.cs @@ -0,0 +1,28 @@ +namespace ConduitLLM.Core.Models +{ + /// + /// Base class for ephemeral key data stored in distributed cache + /// + public abstract class EphemeralKeyDataBase + { + /// + /// The ephemeral key token + /// + public string Key { get; set; } = string.Empty; + + /// + /// When the key was created + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// When the key expires + /// + public DateTimeOffset ExpiresAt { get; set; } + + /// + /// Whether the key has been consumed + /// + public bool IsConsumed { get; set; } + } +} diff --git a/Shared/ConduitLLM.Core/Models/PromptCachingConfig.cs b/Shared/ConduitLLM.Core/Models/PromptCachingConfig.cs new file mode 100644 index 000000000..0ddba249d --- /dev/null +++ b/Shared/ConduitLLM.Core/Models/PromptCachingConfig.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace ConduitLLM.Core.Models; + +/// +/// Configuration for automatic prompt caching injection. +/// Stored as a GlobalSetting with key "PromptCaching.Config". +/// +public class PromptCachingConfig +{ + /// + /// Whether automatic cache_control injection is enabled. + /// + [JsonPropertyName("auto_inject_enabled")] + public bool AutoInjectEnabled { get; set; } + + /// + /// The injection points defining which messages get cache_control directives. + /// + [JsonPropertyName("injection_points")] + public List InjectionPoints { get; set; } = new(); +} + +/// +/// Defines a target for automatic cache_control injection. +/// +public class CacheInjectionPoint +{ + /// + /// Target by role: "system", "user", "assistant". Null matches any role. + /// + [JsonPropertyName("role")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Role { get; set; } + + /// + /// Target by index: 0 = first matching, -1 = last matching, -2 = second-to-last. + /// Null means all messages matching the role filter. + /// + [JsonPropertyName("index")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Index { get; set; } +} diff --git a/Shared/ConduitLLM.Core/Models/SignalR/SignalRMessage.cs b/Shared/ConduitLLM.Core/Models/SignalR/SignalRMessage.cs index 4bd7aa76e..7300d625e 100644 --- a/Shared/ConduitLLM.Core/Models/SignalR/SignalRMessage.cs +++ b/Shared/ConduitLLM.Core/Models/SignalR/SignalRMessage.cs @@ -24,6 +24,32 @@ public abstract class SignalRMessage /// Retry count if message delivery fails /// public int RetryCount { get; set; } + + /// + /// Type of the message for routing and processing. + /// Override in derived classes to specify the message type. + /// + public virtual string MessageType => GetType().Name; + + /// + /// Priority of the message (higher values = higher priority) + /// + public int Priority { get; set; } = 0; + + /// + /// Indicates if this is a critical message that must be delivered + /// + public bool IsCritical { get; set; } = false; + + /// + /// Expiration time for the message (null = no expiration) + /// + public DateTime? ExpiresAt { get; set; } + + /// + /// Checks if the message has expired + /// + public bool IsExpired => ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value; } /// diff --git a/Shared/ConduitLLM.Core/Models/ToolExecutionEvent.cs b/Shared/ConduitLLM.Core/Models/ToolExecutionEvent.cs new file mode 100644 index 000000000..fe628ab4a --- /dev/null +++ b/Shared/ConduitLLM.Core/Models/ToolExecutionEvent.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace ConduitLLM.Core.Models; + +/// +/// Strongly-typed event emitted during agentic tool execution. +/// Replaces anonymous objects to provide compile-time safety and eliminate reflection. +/// +public class ToolExecutionEvent +{ + /// + /// The tool call ID from the LLM response. + /// + [JsonPropertyName("tool_call_id")] + public string? ToolCallId { get; set; } + + /// + /// Name of the function being executed. + /// + [JsonPropertyName("function_name")] + public string? FunctionName { get; set; } + + /// + /// Execution status: "started", "completed", or "failed". + /// + [JsonPropertyName("status")] + public required string Status { get; set; } + + /// + /// Cost of the function execution (populated on completed/failed). + /// + [JsonPropertyName("cost")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public decimal? Cost { get; set; } + + /// + /// Error message if the function failed (populated on failed). + /// + [JsonPropertyName("error_message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ErrorMessage { get; set; } + + /// + /// ID of the FunctionExecution record for audit/drill-down (populated on completed/failed). + /// + [JsonPropertyName("function_execution_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? FunctionExecutionId { get; set; } +} diff --git a/Shared/ConduitLLM.Core/Policies/EvictionPolicies.cs b/Shared/ConduitLLM.Core/Policies/EvictionPolicies.cs index d0f9acac5..967a0ff4b 100644 --- a/Shared/ConduitLLM.Core/Policies/EvictionPolicies.cs +++ b/Shared/ConduitLLM.Core/Policies/EvictionPolicies.cs @@ -252,7 +252,7 @@ public override Task> SelectForEvictionAsync( CachePolicyContext context, CancellationToken cancellationToken = default) { - if (Policies.Count() == 0) + if (!Policies.Any()) return Task.FromResult(Enumerable.Empty()); var entriesList = entries.ToList(); @@ -297,7 +297,7 @@ public override Task> SelectForEvictionAsync( /// public override double CalculateEvictionScore(ICacheEntry entry) { - if (Policies.Count() == 0) + if (!Policies.Any()) return 0; double totalWeight = Policies.Sum(p => p.Weight); diff --git a/Shared/ConduitLLM.Core/Policies/HttpRetryPolicies.cs b/Shared/ConduitLLM.Core/Policies/HttpRetryPolicies.cs new file mode 100644 index 000000000..7bbd4b157 --- /dev/null +++ b/Shared/ConduitLLM.Core/Policies/HttpRetryPolicies.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; + +namespace ConduitLLM.Core.Policies; + +/// +/// Shared HTTP retry policies for use across the solution. +/// Centralizes retry logic to avoid duplication and ensure consistent behavior. +/// +public static class HttpRetryPolicies +{ + /// + /// Standard retry policy for HTTP requests with exponential backoff and jitter. + /// Handles transient HTTP errors (5xx, connection failures) and 429 Too Many Requests. + /// + public static IAsyncPolicy GetStandardRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)) + ); + } + + /// + /// Retry policy for media downloads with exponential backoff and optional logging. + /// + /// Base for exponential backoff (e.g., 2 for images, 3 for videos). + /// Label for log messages (e.g., "Image", "Video"). + public static IAsyncPolicy GetMediaDownloadRetryPolicy( + int backoffBase = 2, + string mediaType = "Media") + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync( + 3, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(backoffBase, retryAttempt)), + onRetry: (outcome, timespan, retryCount, context) => + { + var logger = context.Values.FirstOrDefault() as Microsoft.Extensions.Logging.ILogger; + logger?.LogWarning("{MediaType} download retry {RetryCount} after {Delay}ms", + mediaType, retryCount, timespan.TotalMilliseconds); + }); + } +} diff --git a/Shared/ConduitLLM.Core/Providers/BaseProviderMetadata.cs b/Shared/ConduitLLM.Core/Providers/BaseProviderMetadata.cs index b50915301..463b3ad92 100644 --- a/Shared/ConduitLLM.Core/Providers/BaseProviderMetadata.cs +++ b/Shared/ConduitLLM.Core/Providers/BaseProviderMetadata.cs @@ -90,7 +90,7 @@ public virtual ValidationResult ValidateConfiguration(Dictionary } } - return errors.Count() > 0 + return errors.Any() ? new ValidationResult { IsValid = false, Errors = errors } : ValidationResult.Success(); } diff --git a/Shared/ConduitLLM.Core/Providers/Metadata/AllProviders.cs b/Shared/ConduitLLM.Core/Providers/Metadata/AllProviders.cs index 1cb921154..fb06e720f 100644 --- a/Shared/ConduitLLM.Core/Providers/Metadata/AllProviders.cs +++ b/Shared/ConduitLLM.Core/Providers/Metadata/AllProviders.cs @@ -177,4 +177,81 @@ public DeepInfraProviderMetadata() } } + /// + /// Provider metadata for OpenRouter. + /// + public class OpenRouterProviderMetadata : BaseProviderMetadata + { + public override ProviderType ProviderType => ProviderType.OpenRouter; + public override string DisplayName => "OpenRouter"; + public override string DefaultBaseUrl => "https://openrouter.ai/api/v1"; + + public OpenRouterProviderMetadata() + { + // OpenRouter supports chat completions with features dependent on routed model + Capabilities.Features.Streaming = true; + Capabilities.Features.VisionInput = true; + + // Chat parameters support (dependent on routed model) + Capabilities.ChatParameters.Tools = true; + Capabilities.ChatParameters.ResponseFormat = true; + Capabilities.ChatParameters.Seed = true; + + ConfigurationHints.DocumentationUrl = "https://openrouter.ai/docs"; + ConfigurationHints.Tips.Add(new ConfigurationTip + { + Title = "Multi-Provider Router", + Description = "OpenRouter routes requests to 100+ models from providers like OpenAI, Anthropic, Google, and Meta through a single API", + Severity = TipSeverity.Info + }); + ConfigurationHints.Tips.Add(new ConfigurationTip + { + Title = "Model Naming", + Description = "OpenRouter models use provider/model-name format (e.g., openai/gpt-4o, anthropic/claude-3.5-sonnet)", + Severity = TipSeverity.Info + }); + } + } + + /// + /// Provider metadata for Cloudflare Workers AI. + /// + public class CloudflareProviderMetadata : BaseProviderMetadata + { + public override ProviderType ProviderType => ProviderType.Cloudflare; + public override string DisplayName => "Cloudflare Workers AI"; + public override string DefaultBaseUrl => "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1"; + + public CloudflareProviderMetadata() + { + // Cloudflare Workers AI supports OpenAI-compatible features + Capabilities.Features.Streaming = true; + Capabilities.Features.Embeddings = true; + Capabilities.Features.ImageGeneration = true; + + // Chat parameters support + Capabilities.ChatParameters.Tools = true; + Capabilities.ChatParameters.ResponseFormat = true; + + AuthRequirements.CustomFields = new List + { + CreateUrlField("baseUrl", "API Base URL (must include account ID)", true, + "Format: https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1") + }; + + ConfigurationHints.DocumentationUrl = "https://developers.cloudflare.com/workers-ai/"; + ConfigurationHints.Tips.Add(new ConfigurationTip + { + Title = "Account ID Required", + Description = "The base URL must include your Cloudflare account ID. Find it in the Cloudflare dashboard.", + Severity = TipSeverity.Warning + }); + ConfigurationHints.Tips.Add(new ConfigurationTip + { + Title = "Model Naming", + Description = "Cloudflare models use the @cf/provider/model-name format (e.g., @cf/meta/llama-3.3-70b-instruct-fp8-fast)", + Severity = TipSeverity.Info + }); + } + } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/Abstractions/MediaGenerationOrchestrator.cs b/Shared/ConduitLLM.Core/Services/Abstractions/MediaGenerationOrchestrator.cs index 86f97fda8..edc07ad91 100644 --- a/Shared/ConduitLLM.Core/Services/Abstractions/MediaGenerationOrchestrator.cs +++ b/Shared/ConduitLLM.Core/Services/Abstractions/MediaGenerationOrchestrator.cs @@ -9,6 +9,7 @@ using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Metrics; using ConduitLLM.Core.Models; +using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Validation; using MassTransit; using Microsoft.Extensions.Logging; @@ -53,6 +54,7 @@ public abstract class MediaGenerationOrchestrator context) var request = context.Message; var stopwatch = Stopwatch.StartNew(); GenerationModelInfo? modelInfo = null; - + // Check if request should be processed if (!ShouldProcessRequest(request)) { @@ -101,23 +105,32 @@ public async Task Consume(ConsumeContext context) return; } + // Start distributed tracing span for the entire generation pipeline + using var activity = MediaGenerationMetrics.StartGenerationActivity( + $"media.{GetMediaType().ToLowerInvariant()}.generate", + GetMediaType(), + GetModel(request), + "pending"); // Provider not yet known; updated below after model resolution + activity?.SetTag("media.request_id", GetRequestId(request)); + activity?.SetTag("media.virtual_key_id", GetVirtualKeyId(request)); + // Create linked cancellation token for this task using var taskCts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken); - + // Register task for cancellation support _taskRegistry.RegisterTask(GetRequestId(request), taskCts); try { - _logger.LogInformation("Processing {MediaType} generation task {RequestId} for model {Model}", + _logger.LogInformation("Processing {MediaType} generation task {RequestId} for model {Model}", GetMediaType(), GetRequestId(request), GetModel(request)); - + // 1. Update task status to processing await UpdateTaskStatusAsync(GetRequestId(request), TaskState.Processing, taskCts.Token); - + // 2. Publish started event await PublishStartedEventAsync(request); - + // 3. Get and validate model information var virtualKeyIdStr = GetVirtualKeyId(request); if (!int.TryParse(virtualKeyIdStr, out var virtualKeyId)) @@ -129,40 +142,44 @@ public async Task Consume(ConsumeContext context) { throw new InvalidOperationException($"Model '{GetModel(request)}' is not configured or mapped to a provider. Please check your model configuration."); } - + + // Update the activity with resolved provider information + activity?.SetTag("media.provider", modelInfo.ProviderName); + activity?.SetTag("media.model", modelInfo.ModelId); + ValidateModelSupport(modelInfo, request); - + // Record generation started metrics _metrics.RecordGenerationStarted( - GetMediaType(), - GetModel(request), - modelInfo.ProviderName, + GetMediaType(), + GetModel(request), + modelInfo.ProviderName, virtualKeyIdStr); - + // Update task registry size _metrics.UpdateTaskRegistrySize(1); - + // 4. Extract and validate virtual key var virtualKey = await ExtractAndValidateVirtualKeyAsync(request); - + // 5. Build the generation request var generationRequest = await BuildGenerationRequestAsync(request, modelInfo); - + // 6. Validate parameters ValidateParameters(generationRequest); - + // 7. Log generation details LogGenerationDetails(request, modelInfo, generationRequest); - + // 8. Execute the actual generation var response = await ExecuteGenerationAsync(generationRequest, modelInfo, virtualKey, taskCts.Token); - + // 9. Process and store the generated media var processedMedia = await ProcessMediaAsync(response, request, modelInfo, virtualKey, taskCts.Token); - + // 10. Calculate cost var cost = await CalculateCostAsync(request, modelInfo, processedMedia); - + // 11. Update spend if (cost > 0) { @@ -171,25 +188,33 @@ public async Task Consume(ConsumeContext context) await UpdateSpendAsync(vkId, cost, GetRequestId(request), GetCorrelationId(request)); } } - + // 12. Complete the task await CompleteTaskAsync(request, processedMedia, cost, modelInfo, stopwatch); - + // 13. Send webhook notification if configured if (!string.IsNullOrEmpty(GetWebhookUrl(request))) { await SendWebhookNotificationAsync(request, processedMedia, stopwatch, "completed"); } - + + activity?.SetTag("media.cost", cost); + activity?.SetTag("media.duration_seconds", stopwatch.Elapsed.TotalSeconds); + _logger.LogInformation("Completed {MediaType} generation task {RequestId} in {Duration}s", GetMediaType(), GetRequestId(request), stopwatch.Elapsed.TotalSeconds); } catch (OperationCanceledException) when (taskCts.Token.IsCancellationRequested) { + activity?.SetStatus(ActivityStatusCode.Error, "Cancelled"); + activity?.SetTag("media.outcome", "cancelled"); await HandleCancellationAsync(request, stopwatch, modelInfo); } catch (Exception ex) { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("media.outcome", "failed"); + activity?.SetTag("media.error_type", ex.GetType().Name); await HandleFailureAsync(request, ex, stopwatch, modelInfo); } finally @@ -329,16 +354,53 @@ protected virtual async Task UpdateTaskStatusAsync(string taskId, TaskState stat protected virtual async Task CompleteTaskAsync(TEventRequest request, ProcessedMedia media, decimal cost, GenerationModelInfo modelInfo, Stopwatch stopwatch) { + // Build data array from processed media items in OpenAI-compatible format + // This format is expected by SDKs: { created, data: [{ url, metadata }], model, usage } + var dataItems = new List(); + + if (media.Items.Any()) + { + foreach (var item in media.Items) + { + dataItems.Add(new + { + url = item.Url, + metadata = item.Metadata.Count > 0 ? item.Metadata : null + }); + } + } + else if (!string.IsNullOrEmpty(media.Url)) + { + // Single item case - wrap in data array + dataItems.Add(new + { + url = media.Url, + metadata = media.Metadata.Count > 0 ? media.Metadata : null + }); + } + + // Create result in OpenAI-compatible format that SDKs expect + // Both ImageGenerationResponse and VideoGenerationResponse share this structure var result = new { - mediaUrl = media.Url ?? media.Items.FirstOrDefault()?.Url, - mediaCount = media.Count, - duration = stopwatch.Elapsed.TotalSeconds, - cost, - provider = modelInfo.ProviderName, - model = modelInfo.ModelId + created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + data = dataItems, + model = modelInfo.ModelId, + usage = new + { + // Generic usage info - specific orchestrators can override if needed + count = media.Count, + duration_seconds = stopwatch.Elapsed.TotalSeconds + }, + // Additional metadata for internal use (not part of OpenAI spec but useful) + _metadata = new + { + cost, + provider = modelInfo.ProviderName, + generation_duration_seconds = stopwatch.Elapsed.TotalSeconds + } }; - + // Record completion metrics _metrics.RecordGenerationCompleted( GetMediaType(), @@ -347,16 +409,16 @@ protected virtual async Task CompleteTaskAsync(TEventRequest request, ProcessedM GetVirtualKeyId(request), stopwatch.Elapsed.TotalSeconds, (double)cost); - + // Update task registry size _metrics.UpdateTaskRegistrySize(-1); - + await _taskService.UpdateTaskStatusAsync( GetRequestId(request), TaskState.Completed, progress: 100, result: result); - + await PublishCompletedEventAsync(request, media, cost, modelInfo, stopwatch.Elapsed); } @@ -447,15 +509,97 @@ await _taskService.UpdateTaskStatusAsync( GetRequestId(request), TaskState.Failed, error: ex.Message); - + + // Track in provider error system for dashboard visibility and auto-disable policies + await TrackProviderErrorFromExceptionAsync(ex, modelInfo); + await PublishFailedEventAsync(request, ex, isRetryable, 0, 0); - + if (!string.IsNullOrEmpty(GetWebhookUrl(request))) { await SendWebhookNotificationAsync(request, null, stopwatch, "failed", ex.Message); } } + /// + /// Tracks a provider error from an exception using the provider error tracking system. + /// + private async Task TrackProviderErrorFromExceptionAsync(Exception ex, GenerationModelInfo? modelInfo) + { + try + { + if (modelInfo?.Provider == null) + { + _logger.LogDebug("Cannot track provider error โ€” no provider context available"); + return; + } + + var keyCredentialId = modelInfo.Provider.ProviderKeyCredentials? + .FirstOrDefault(k => k.IsPrimary)?.Id + ?? modelInfo.Provider.ProviderKeyCredentials?.FirstOrDefault()?.Id; + + if (keyCredentialId == null) + { + _logger.LogDebug("Cannot track provider error โ€” no key credential found for provider {ProviderId}", + modelInfo.ProviderId); + return; + } + + var errorType = ClassifyExceptionToProviderErrorType(ex); + + var errorInfo = new ProviderErrorInfo + { + KeyCredentialId = keyCredentialId.Value, + ProviderId = modelInfo.ProviderId, + ErrorType = errorType, + ErrorMessage = ex.Message, + HttpStatusCode = (ex as LLMCommunicationException)?.StatusCode.HasValue == true + ? (int)(ex as LLMCommunicationException)!.StatusCode!.Value + : null, + ModelName = modelInfo.ModelId, + OccurredAt = DateTime.UtcNow + }; + + await _errorTrackingService.TrackErrorAsync(errorInfo); + + _logger.LogInformation("Tracked {MediaType} generation provider error: Type={ErrorType}, Provider={ProviderId}, Key={KeyCredentialId}, Model={Model}", + GetMediaType(), errorType, modelInfo.ProviderId, keyCredentialId, modelInfo.ModelId); + } + catch (Exception trackEx) + { + _logger.LogWarning(trackEx, "Failed to track provider error for {MediaType} generation", GetMediaType()); + } + } + + /// + /// Classifies an exception into a for error tracking. + /// + private static ProviderErrorType ClassifyExceptionToProviderErrorType(Exception ex) + { + return ex switch + { + LLMCommunicationException commEx when commEx.StatusCode.HasValue => commEx.StatusCode.Value switch + { + System.Net.HttpStatusCode.Unauthorized => ProviderErrorType.InvalidApiKey, + System.Net.HttpStatusCode.PaymentRequired => ProviderErrorType.InsufficientBalance, + System.Net.HttpStatusCode.Forbidden => ProviderErrorType.AccessForbidden, + System.Net.HttpStatusCode.TooManyRequests => ProviderErrorType.RateLimitExceeded, + System.Net.HttpStatusCode.NotFound => ProviderErrorType.ModelNotFound, + System.Net.HttpStatusCode.ServiceUnavailable => ProviderErrorType.ServiceUnavailable, + System.Net.HttpStatusCode.BadGateway => ProviderErrorType.ServiceUnavailable, + System.Net.HttpStatusCode.GatewayTimeout => ProviderErrorType.Timeout, + System.Net.HttpStatusCode.RequestTimeout => ProviderErrorType.Timeout, + _ => ProviderErrorType.Unknown + }, + RateLimitExceededException => ProviderErrorType.RateLimitExceeded, + Exceptions.RequestTimeoutException => ProviderErrorType.Timeout, + ModelNotFoundException => ProviderErrorType.ModelNotFound, + ServiceUnavailableException => ProviderErrorType.ServiceUnavailable, + HttpRequestException => ProviderErrorType.NetworkError, + _ => ProviderErrorType.Unknown + }; + } + protected virtual async Task UpdateSpendAsync(int virtualKeyId, decimal amount, string requestId, string? correlationId) { await _publishEndpoint.Publish(new SpendUpdateRequested diff --git a/Shared/ConduitLLM.Core/Services/BatchCacheInvalidationService.cs b/Shared/ConduitLLM.Core/Services/BatchCacheInvalidationService.cs index 7ff4e84ac..c26e1f067 100644 --- a/Shared/ConduitLLM.Core/Services/BatchCacheInvalidationService.cs +++ b/Shared/ConduitLLM.Core/Services/BatchCacheInvalidationService.cs @@ -165,12 +165,12 @@ public Task GetErrorRateAsync(TimeSpan window) lock (_errorLock) { var cutoff = DateTime.UtcNow - window; - while (_errorTimestamps.Count() > 0 && _errorTimestamps.Peek() < cutoff) + while (_errorTimestamps.Any() && _errorTimestamps.Peek() < cutoff) { _errorTimestamps.Dequeue(); } - var errorCount = _errorTimestamps.Count(); + var errorCount = _errorTimestamps.Count; var totalProcessed = _totalProcessed; return Task.FromResult(totalProcessed > 0 ? errorCount / (double)totalProcessed : 0); @@ -235,7 +235,7 @@ private async Task ProcessAllBatches() } } - if (tasks.Count() > 0) + if (tasks.Any()) { await Task.WhenAll(tasks); _lastProcessedTime = DateTime.UtcNow; @@ -259,7 +259,7 @@ private async Task ProcessBatch(CacheType cacheType) itemsToProcess.Add(item); } - if (itemsToProcess.Count() == 0) + if (!itemsToProcess.Any()) { return; } @@ -465,7 +465,7 @@ private void RecordError() // Keep only last hour of errors var cutoff = DateTime.UtcNow.AddHours(-1); - while (_errorTimestamps.Count() > 0 && _errorTimestamps.Peek() < cutoff) + while (_errorTimestamps.Any() && _errorTimestamps.Peek() < cutoff) { _errorTimestamps.Dequeue(); } diff --git a/Shared/ConduitLLM.Core/Services/BatchOperations/BatchVirtualKeyUpdateOperation.cs b/Shared/ConduitLLM.Core/Services/BatchOperations/BatchVirtualKeyUpdateOperation.cs index 95924f5fb..79480941d 100644 --- a/Shared/ConduitLLM.Core/Services/BatchOperations/BatchVirtualKeyUpdateOperation.cs +++ b/Shared/ConduitLLM.Core/Services/BatchOperations/BatchVirtualKeyUpdateOperation.cs @@ -120,7 +120,7 @@ private async Task ProcessVirtualKeyUpdateAsync( changedProperties.Add($"ExpiresAt: {item.ExpiresAt.Value:yyyy-MM-dd}"); } - if (changedProperties.Count() > 0) + if (changedProperties.Any()) { // Save changes var updated = await _virtualKeyService.UpdateVirtualKeyAsync(item.VirtualKeyId, updateRequest); diff --git a/Shared/ConduitLLM.Core/Services/BatchOperations/BatchWebhookSendOperation.cs b/Shared/ConduitLLM.Core/Services/BatchOperations/BatchWebhookSendOperation.cs index 386fc1f31..041d0a936 100644 --- a/Shared/ConduitLLM.Core/Services/BatchOperations/BatchWebhookSendOperation.cs +++ b/Shared/ConduitLLM.Core/Services/BatchOperations/BatchWebhookSendOperation.cs @@ -87,7 +87,7 @@ private async Task ProcessWebhookSendAsync( var content = new StringContent(json, Encoding.UTF8, "application/json"); // Add headers - var request = new HttpRequestMessage(HttpMethod.Post, item.WebhookUrl) + using var request = new HttpRequestMessage(HttpMethod.Post, item.WebhookUrl) { Content = content }; @@ -111,7 +111,7 @@ private async Task ProcessWebhookSendAsync( using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(30)); - var response = await httpClient.SendAsync(request, cts.Token); + using var response = await httpClient.SendAsync(request, cts.Token); // Notify delivery status if (response.IsSuccessStatusCode) diff --git a/Shared/ConduitLLM.Core/Services/BatchWebhookPublisher.cs b/Shared/ConduitLLM.Core/Services/BatchWebhookPublisher.cs index 0fefbac17..418456e68 100644 --- a/Shared/ConduitLLM.Core/Services/BatchWebhookPublisher.cs +++ b/Shared/ConduitLLM.Core/Services/BatchWebhookPublisher.cs @@ -74,7 +74,11 @@ public void EnqueueWebhook(WebhookDeliveryRequested webhook) // If we've reached the batch size, trigger immediate publishing if (_queue.Count() >= _options.Value.MaxBatchSize) { - _ = Task.Run(async () => await PublishBatchAsync()); + _ = Task.Run(async () => + { + try { await PublishBatchAsync(); } + catch (Exception ex) { _logger.LogError(ex, "Unhandled error during webhook batch publish (threshold)"); } + }); } else { @@ -93,7 +97,11 @@ public void EnqueueWebhooks(IEnumerable webhooks) _queue.Enqueue(webhook); } - _ = Task.Run(async () => await PublishBatchAsync()); + _ = Task.Run(async () => + { + try { await PublishBatchAsync(); } + catch (Exception ex) { _logger.LogError(ex, "Unhandled error during webhook bulk batch publish"); } + }); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -170,7 +178,7 @@ private async Task PublishBatchAsync() batch.Add(webhook); } - if (batch.Count() == 0) + if (!batch.Any()) { return; } diff --git a/Shared/ConduitLLM.Core/Services/BufferedStatsRedisCacheBase.cs b/Shared/ConduitLLM.Core/Services/BufferedStatsRedisCacheBase.cs new file mode 100644 index 000000000..126074e02 --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/BufferedStatsRedisCacheBase.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; + +namespace ConduitLLM.Core.Services; + +/// +/// Redis cache base that buffers hit/miss/invalidation counters locally and flushes +/// them to Redis in batches every 5 seconds, reducing per-operation Redis round-trips. +/// Extends and overrides its tracking methods. +/// +/// +/// Subclasses that need additional custom counters (e.g., pattern match counts) can +/// override and to include them +/// in the periodic and dispose flush cycles. +/// +public abstract class BufferedStatsRedisCacheBase : RedisCacheServiceBase, IDisposable +{ + private long _bufferedHits; + private long _bufferedMisses; + private long _bufferedInvalidations; + + private readonly Timer _flushTimer; + private readonly SemaphoreSlim _flushLock = new(1, 1); + private bool _disposed; + + /// + /// The service name used for stats keys in Redis. + /// + protected abstract string ServiceName { get; } + + /// + /// Pending hits not yet flushed to Redis. Use in GetStatsAsync() implementations. + /// + protected long PendingHits => Interlocked.Read(ref _bufferedHits); + + /// + /// Pending misses not yet flushed to Redis. Use in GetStatsAsync() implementations. + /// + protected long PendingMisses => Interlocked.Read(ref _bufferedMisses); + + /// + /// Pending invalidations not yet flushed to Redis. Use in GetStatsAsync() implementations. + /// + protected long PendingInvalidations => Interlocked.Read(ref _bufferedInvalidations); + + /// + /// Whether this instance has been disposed. + /// + protected bool IsDisposed => _disposed; + + protected BufferedStatsRedisCacheBase( + IConnectionMultiplexer redis, + ILogger logger, + TimeSpan defaultExpiry, + JsonSerializerOptions? jsonOptions = null) + : base(redis, logger, defaultExpiry, jsonOptions) + { + InitializeStatsResetTime(ServiceName); + _flushTimer = new Timer( + _ => _ = FlushStatisticsAsync(), + null, + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5)); + } + + #region Buffered Stats Overrides + + protected sealed override Task TrackHitAsync(string serviceName) + { + Interlocked.Increment(ref _bufferedHits); + return Task.CompletedTask; + } + + protected sealed override Task TrackMissAsync(string serviceName) + { + Interlocked.Increment(ref _bufferedMisses); + return Task.CompletedTask; + } + + protected sealed override Task TrackInvalidationAsync(string serviceName, long count = 1) + { + Interlocked.Add(ref _bufferedInvalidations, count); + return Task.CompletedTask; + } + + #endregion + + #region Flush + + /// + /// Override to add custom counters to the periodic flush batch. + /// Called while the flush lock is held. Use to queue Redis commands. + /// + protected virtual void OnFlush(IBatch batch, List tasks) { } + + /// + /// Override to add custom counters to the final dispose flush. + /// Called while the flush lock is held. Add tasks to the list for . + /// + protected virtual void OnFinalFlush(List tasks) { } + + /// + /// Override to return true if custom counters have pending data. + /// Prevents early-exit from flush when standard counters are all zero. + /// + protected virtual bool HasPendingCustomStats() => false; + + private async Task FlushStatisticsAsync() + { + if (_disposed) return; + if (!await _flushLock.WaitAsync(0)) return; + + try + { + var hits = Interlocked.Exchange(ref _bufferedHits, 0); + var misses = Interlocked.Exchange(ref _bufferedMisses, 0); + var invalidations = Interlocked.Exchange(ref _bufferedInvalidations, 0); + + if (hits == 0 && misses == 0 && invalidations == 0 && !HasPendingCustomStats()) return; + + var batch = Database.CreateBatch(); + var tasks = new List(); + + if (hits > 0) tasks.Add(batch.StringIncrementAsync(CacheKeys.Stats.Hits(ServiceName), hits)); + if (misses > 0) tasks.Add(batch.StringIncrementAsync(CacheKeys.Stats.Misses(ServiceName), misses)); + if (invalidations > 0) tasks.Add(batch.StringIncrementAsync(CacheKeys.Stats.Invalidations(ServiceName), invalidations)); + + OnFlush(batch, tasks); + + batch.Execute(); + await Task.WhenAll(tasks); + + Logger.LogDebug("Flushed {ServiceName} cache stats: Hits={Hits}, Misses={Misses}, Invalidations={Invalidations}", + ServiceName, hits, misses, invalidations); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error flushing {ServiceName} cache statistics", ServiceName); + } + finally + { + _flushLock.Release(); + } + } + + #endregion + + #region Dispose + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _flushTimer.Change(Timeout.Infinite, 0); + _flushTimer.Dispose(); + + try + { + _flushLock.Wait(TimeSpan.FromSeconds(5)); + try + { + var hits = Interlocked.Exchange(ref _bufferedHits, 0); + var misses = Interlocked.Exchange(ref _bufferedMisses, 0); + var invalidations = Interlocked.Exchange(ref _bufferedInvalidations, 0); + + if (hits > 0 || misses > 0 || invalidations > 0 || HasPendingCustomStats()) + { + var tasks = new List(); + if (hits > 0) tasks.Add(Database.StringIncrementAsync(CacheKeys.Stats.Hits(ServiceName), hits)); + if (misses > 0) tasks.Add(Database.StringIncrementAsync(CacheKeys.Stats.Misses(ServiceName), misses)); + if (invalidations > 0) tasks.Add(Database.StringIncrementAsync(CacheKeys.Stats.Invalidations(ServiceName), invalidations)); + + OnFinalFlush(tasks); + + Task.WaitAll(tasks.ToArray(), TimeSpan.FromSeconds(5)); + } + } + finally + { + _flushLock.Release(); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during final {ServiceName} cache statistics flush on dispose", ServiceName); + } + + _flushLock.Dispose(); + } + + #endregion +} diff --git a/Shared/ConduitLLM.Core/Services/CacheManager.Helpers.cs b/Shared/ConduitLLM.Core/Services/CacheManager.Helpers.cs index eba52f08d..c0d16843b 100644 --- a/Shared/ConduitLLM.Core/Services/CacheManager.Helpers.cs +++ b/Shared/ConduitLLM.Core/Services/CacheManager.Helpers.cs @@ -10,6 +10,8 @@ namespace ConduitLLM.Core.Services /// public partial class CacheManager { + // Per-key semaphores for local locking (fixes cache stampede within same instance) + private readonly ConcurrentDictionary _localLocks = new(); private void InitializeDefaultConfigurations(CacheManagerOptions? options) { var defaultConfigs = new Dictionary @@ -30,6 +32,7 @@ private void InitializeDefaultConfigurations(CacheManagerOptions? options) { CacheRegion.LLMCompletion, (TimeSpan.FromMinutes(30), 60, true) }, { CacheRegion.AudioStreams, (TimeSpan.FromMinutes(10), 30, false) }, { CacheRegion.Monitoring, (TimeSpan.FromMinutes(5), 45, false) }, + { CacheRegion.PricingRules, (TimeSpan.FromMinutes(15), 50, true) }, { CacheRegion.Default, (TimeSpan.FromMinutes(15), 50, false) } }; @@ -113,10 +116,10 @@ private void OnMemoryCacheEviction(object key, object? value, EvictionReason rea private async Task AcquireLockAsync(string lockKey, CancellationToken cancellationToken) { - // Simple in-memory lock implementation. In production, use distributed locks for distributed cache - var semaphore = new SemaphoreSlim(1, 1); + // Get or create a per-key semaphore to prevent same-instance stampedes + var semaphore = _localLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); await semaphore.WaitAsync(cancellationToken); - return new DisposableLock(semaphore); + return new DisposableLock(semaphore, lockKey, _localLocks); } private CacheRegionConfig CreateDefaultConfig(CacheRegion region) @@ -155,15 +158,25 @@ private long EstimateObjectSize(object obj) private class DisposableLock : IDisposable { private readonly SemaphoreSlim _semaphore; + private readonly string _lockKey; + private readonly ConcurrentDictionary _locks; - public DisposableLock(SemaphoreSlim semaphore) + public DisposableLock(SemaphoreSlim semaphore, string lockKey, ConcurrentDictionary locks) { _semaphore = semaphore; + _lockKey = lockKey; + _locks = locks; } public void Dispose() { _semaphore.Release(); + // Cleanup: Remove semaphore from dictionary if no one is waiting + // This prevents memory leaks from accumulating semaphores for stale keys + if (_semaphore.CurrentCount == 1) + { + _locks.TryRemove(_lockKey, out _); + } } } } diff --git a/Shared/ConduitLLM.Core/Services/CachePolicyEngine.cs b/Shared/ConduitLLM.Core/Services/CachePolicyEngine.cs index 2a8a37d36..6aaa1a0f2 100644 --- a/Shared/ConduitLLM.Core/Services/CachePolicyEngine.cs +++ b/Shared/ConduitLLM.Core/Services/CachePolicyEngine.cs @@ -138,7 +138,7 @@ public IEnumerable GetPolicies(CacheRegion? region = null) where T : ICach .OrderByDescending(p => p.Priority) .ToList(); - if (ttlPolicies.Count() == 0) + if (!ttlPolicies.Any()) return null; DateTime? shortestExpiration = null; @@ -178,7 +178,7 @@ public bool ApplySizePolicies(ICacheEntry entry, long currentSize, CachePolicyCo .OrderByDescending(p => p.Priority) .ToList(); - if (sizePolicies.Count() == 0) + if (!sizePolicies.Any()) return true; // No size restrictions foreach (var policy in sizePolicies) @@ -217,7 +217,7 @@ public async Task> ApplyEvictionPoliciesAsync( .OrderByDescending(p => p.Priority) .ToList(); - if (evictionPolicies.Count() == 0) + if (!evictionPolicies.Any()) { // Default: evict oldest entries return entries diff --git a/Shared/ConduitLLM.Core/Services/CacheStatisticsCollector.cs b/Shared/ConduitLLM.Core/Services/CacheStatisticsCollector.cs index 23f8ecc8b..bacecd120 100644 --- a/Shared/ConduitLLM.Core/Services/CacheStatisticsCollector.cs +++ b/Shared/ConduitLLM.Core/Services/CacheStatisticsCollector.cs @@ -354,16 +354,16 @@ private CacheStatistics ConvertToPublicStatistics(RegionStatistics stats) }; // Calculate response times - if (stats.ResponseTimes.Count() > 0) + if (stats.ResponseTimes.Any()) { var getTimes = stats.ResponseTimes - .Where(rt => rt.Operation == CacheOperationType.Get || - rt.Operation == CacheOperationType.Hit || + .Where(rt => rt.Operation == CacheOperationType.Get || + rt.Operation == CacheOperationType.Hit || rt.Operation == CacheOperationType.Miss) .Select(rt => rt.Duration) .ToList(); - if (getTimes.Count() > 0) + if (getTimes.Any()) { publicStats.AverageGetTime = TimeSpan.FromMilliseconds(getTimes.Average(t => t.TotalMilliseconds)); publicStats.P95GetTime = CalculatePercentile(getTimes, 95); @@ -377,7 +377,7 @@ private CacheStatistics ConvertToPublicStatistics(RegionStatistics stats) private TimeSpan CalculatePercentile(List values, int percentile) { - if (values.Count() == 0) + if (!values.Any()) return TimeSpan.Zero; var sorted = values.OrderBy(v => v).ToList(); @@ -433,7 +433,13 @@ private void AggregateStatistics(object? state) } } - private async void PersistStatistics(object? state) + private void PersistStatistics(object? state) + { + // Fire-and-forget with proper exception handling - don't use async void + _ = PersistStatisticsAsync(); + } + + private async Task PersistStatisticsAsync() { if (_store == null) return; diff --git a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Monitoring.cs b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Monitoring.cs index a928e8c93..ab260886a 100644 --- a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Monitoring.cs +++ b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Monitoring.cs @@ -106,7 +106,7 @@ private async Task GetRedisMemoryUsageAsync() var info = await server.InfoAsync("memory"); var memorySection = info.FirstOrDefault(s => s.Key == "Memory"); - if (memorySection != null && memorySection.Count() > 0) + if (memorySection != null && memorySection.Any()) { var usedMemory = memorySection.FirstOrDefault(kvp => kvp.Key == "used_memory"); if (usedMemory.Value != null && long.TryParse(usedMemory.Value, out var bytes)) diff --git a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Performance.cs b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Performance.cs index d465861e8..0cb23acc7 100644 --- a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Performance.cs +++ b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Performance.cs @@ -29,7 +29,7 @@ private async Task GetPerformanceMetricsAsyncImpl( .OrderBy(l => l) .ToList(); - if (recordingLatencies.Count() > 0) + if (recordingLatencies.Any()) { metrics.AvgRecordingLatencyMs = recordingLatencies.Average(); metrics.P95RecordingLatencyMs = GetPercentile(recordingLatencies, 0.95); @@ -43,7 +43,7 @@ private async Task GetPerformanceMetricsAsyncImpl( .OrderBy(l => l) .ToList(); - if (aggregationLatencies.Count() > 0) + if (aggregationLatencies.Any()) { metrics.AvgAggregationLatencyMs = aggregationLatencies.Average(); } @@ -111,7 +111,7 @@ private double GetLatestAggregationLatency() if (_performanceTrackers.TryGetValue("aggregate:overall", out var tracker)) { var latencies = tracker.GetLatencies(); - return latencies.Count() > 0 ? latencies.Last() : 0; + return latencies.Any() ? latencies.Last() : 0; } return 0; } @@ -127,7 +127,7 @@ private double GetLatestAggregationLatency() private double GetPercentile(List sortedValues, double percentile) { - if (sortedValues.Count() == 0) return 0; + if (!sortedValues.Any()) return 0; var index = (int)Math.Ceiling(percentile * sortedValues.Count()) - 1; return sortedValues[Math.Max(0, Math.Min(index, sortedValues.Count() - 1))]; @@ -167,7 +167,7 @@ public void RecordOperation() // Remove old operations outside time window var cutoff = now.AddSeconds(-MaxTimeWindowSeconds); - while (_operationTimes.Count() > 0 && _operationTimes.Peek() < cutoff) + while (_operationTimes.Any() && _operationTimes.Peek() < cutoff) { _operationTimes.Dequeue(); } diff --git a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Validation.cs b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Validation.cs index 3c5653448..473c64bea 100644 --- a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Validation.cs +++ b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.Validation.cs @@ -29,7 +29,7 @@ private async Task PerformAccuracyValidationAsync(Canc // Get per-instance statistics var perInstance = await _statisticsCollector.GetPerInstanceStatisticsAsync(region, cancellationToken); - if (perInstance.Count() == 0) continue; + if (!perInstance.Any()) continue; // Validate hit count var sumHitCount = perInstance.Sum(kvp => kvp.Value.HitCount); @@ -74,13 +74,13 @@ private async Task PerformAccuracyValidationAsync(Canc } // Check for instances with suspiciously high variance - var avgHitCount = perInstance.Count() > 0 ? perInstance.Average(kvp => kvp.Value.HitCount) : 0; + var avgHitCount = perInstance.Any() ? perInstance.Average(kvp => kvp.Value.HitCount) : 0; var outliers = perInstance .Where(kvp => Math.Abs(kvp.Value.HitCount - avgHitCount) > avgHitCount * 0.5) // 50% variance .Select(kvp => kvp.Key) .ToList(); - if (outliers.Count() > 0) + if (outliers.Any()) { report.InconsistentInstances.AddRange(outliers); } diff --git a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.cs b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.cs index 028367e83..5a0d157d1 100644 --- a/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.cs +++ b/Shared/ConduitLLM.Core/Services/CacheStatisticsHealthCheck.cs @@ -153,10 +153,10 @@ private async Task PerformHealthCheckAsync(Cancella result.MissingInstances = missingInstances.Count(); - if (missingInstances.Count() > 0) + if (missingInstances.Any()) { result.Status = HealthStatus.Degraded; - result.Messages.Add($"{missingInstances.Count()} instances not reporting"); + result.Messages.Add($"{missingInstances.Count} instances not reporting"); } // Check aggregation performance diff --git a/Shared/ConduitLLM.Core/Services/CachedModelCostService.cs b/Shared/ConduitLLM.Core/Services/CachedModelCostService.cs new file mode 100644 index 000000000..d283be645 --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/CachedModelCostService.cs @@ -0,0 +1,199 @@ +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Services +{ + /// + /// Caching decorator for IModelCostService that reduces database load + /// by caching model cost lookups via the CacheManager. + /// + /// + /// This decorator implements the Decorator pattern to add caching capabilities + /// to the existing ModelCostService without modifying its core logic. + /// + /// Caching Strategy: + /// - Uses CacheRegion.ModelCosts for model cost data + /// - TTL: 15 minutes (region default) + /// - Read methods use Get + Set (not GetOrCreate) because results can be null + /// - Write methods delegate to inner service then clear the entire region + /// - Graceful fallback to inner service on cache errors + /// + public class CachedModelCostService : IModelCostService + { + private readonly IModelCostService _innerService; + private readonly ICacheManager _cacheManager; + private readonly ILogger _logger; + + private const CacheRegion Region = CacheRegion.ModelCosts; + + public CachedModelCostService( + IModelCostService innerService, + ICacheManager cacheManager, + ILogger logger) + { + _innerService = innerService ?? throw new ArgumentNullException(nameof(innerService)); + _cacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task GetCostForModelAsync(string modelId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(modelId)) + { + throw new ArgumentException("Model ID cannot be empty", nameof(modelId)); + } + + var cacheKey = CacheKeys.ModelCost.ByModelId(modelId); + + try + { + var cached = await _cacheManager.GetAsync(cacheKey, Region, cancellationToken); + if (cached != null) + { + _logger.LogDebug("Cache hit for model cost: {ModelId}", modelId); + return cached; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache read failed for model cost {ModelId}, falling back to database", modelId); + } + + var result = await _innerService.GetCostForModelAsync(modelId, cancellationToken); + + if (result != null) + { + try + { + await _cacheManager.SetAsync(cacheKey, result, Region, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache write failed for model cost {ModelId}", modelId); + } + } + + return result; + } + + /// + public async Task GetCostByIdAsync(int modelCostId, CancellationToken cancellationToken = default) + { + var cacheKey = CacheKeys.ModelCost.ById(modelCostId); + + try + { + var cached = await _cacheManager.GetAsync(cacheKey, Region, cancellationToken); + if (cached != null) + { + _logger.LogDebug("Cache hit for model cost ID: {ModelCostId}", modelCostId); + return cached; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache read failed for model cost ID {ModelCostId}, falling back to database", modelCostId); + } + + var result = await _innerService.GetCostByIdAsync(modelCostId, cancellationToken); + + if (result != null) + { + try + { + await _cacheManager.SetAsync(cacheKey, result, Region, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache write failed for model cost ID {ModelCostId}", modelCostId); + } + } + + return result; + } + + /// + public async Task> ListModelCostsAsync(CancellationToken cancellationToken = default) + { + try + { + var cached = await _cacheManager.GetAsync>(CacheKeys.ModelCost.All, Region, cancellationToken); + if (cached != null) + { + return cached; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache read failed for all model costs, falling back to database"); + } + + var result = await _innerService.ListModelCostsAsync(cancellationToken); + + try + { + await _cacheManager.SetAsync(CacheKeys.ModelCost.All, result, Region, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache write failed for all model costs"); + } + + return result; + } + + /// + public async Task AddModelCostAsync(ModelCost modelCost, CancellationToken cancellationToken = default) + { + await _innerService.AddModelCostAsync(modelCost, cancellationToken); + await InvalidateAllAsync(cancellationToken); + } + + /// + public async Task UpdateModelCostAsync(ModelCost modelCost, CancellationToken cancellationToken = default) + { + var result = await _innerService.UpdateModelCostAsync(modelCost, cancellationToken); + if (result) + { + await InvalidateAllAsync(cancellationToken); + } + return result; + } + + /// + public async Task DeleteModelCostAsync(int id, CancellationToken cancellationToken = default) + { + var result = await _innerService.DeleteModelCostAsync(id, cancellationToken); + if (result) + { + await InvalidateAllAsync(cancellationToken); + } + return result; + } + + /// + public async Task ClearCacheAsync(CancellationToken cancellationToken = default) + { + await InvalidateAllAsync(cancellationToken); + } + + private async Task InvalidateAllAsync(CancellationToken cancellationToken) + { + try + { + await _cacheManager.ClearRegionAsync(Region, cancellationToken); + _logger.LogInformation("Model cost cache region cleared"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error clearing model cost cache region"); + } + } + } +} diff --git a/Shared/ConduitLLM.Core/Services/CachedModelProviderMappingService.cs b/Shared/ConduitLLM.Core/Services/CachedModelProviderMappingService.cs index 5b9800ba6..c5e9105e2 100644 --- a/Shared/ConduitLLM.Core/Services/CachedModelProviderMappingService.cs +++ b/Shared/ConduitLLM.Core/Services/CachedModelProviderMappingService.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Configuration.Constants; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Interfaces; @@ -37,12 +38,6 @@ public class CachedModelProviderMappingService : IModelProviderMappingService private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(CacheDurationMinutes); private const CacheRegion Region = CacheRegion.ModelMetadata; - // Cache key patterns - private const string CacheKeyPrefix = "model:mapping"; - private const string ByAliasKeyPattern = "model:mapping:{0}"; - private const string ByIdKeyPattern = "model:mapping:id:{0}"; - private const string AllMappingsKey = "model:mapping:all"; - public CachedModelProviderMappingService( IModelProviderMappingService innerService, ICacheManager cacheManager, @@ -58,7 +53,7 @@ public CachedModelProviderMappingService( /// public async Task GetMappingByIdAsync(int id) { - var cacheKey = string.Format(ByIdKeyPattern, id); + var cacheKey = CacheKeys.ModelMapping.ById(id); try { @@ -89,7 +84,7 @@ public CachedModelProviderMappingService( throw new ArgumentException("Model alias cannot be null or empty", nameof(modelAlias)); } - var cacheKey = string.Format(ByAliasKeyPattern, modelAlias); + var cacheKey = CacheKeys.ModelMapping.ByAlias(modelAlias); try { @@ -117,7 +112,7 @@ public async Task> GetAllMappingsAsync() try { var cached = await _cacheManager.GetOrCreateAsync( - AllMappingsKey, + CacheKeys.ModelMapping.AllMappings, async () => await _innerService.GetAllMappingsAsync(), Region, CacheTtl); @@ -256,16 +251,16 @@ private async Task InvalidateMappingCacheAsync(string? modelAlias, int id) var keysToRemove = new List(); // Always invalidate the ID-based key - keysToRemove.Add(string.Format(ByIdKeyPattern, id)); + keysToRemove.Add(CacheKeys.ModelMapping.ById(id)); // Invalidate alias-based key if we know the alias if (!string.IsNullOrEmpty(modelAlias)) { - keysToRemove.Add(string.Format(ByAliasKeyPattern, modelAlias)); + keysToRemove.Add(CacheKeys.ModelMapping.ByAlias(modelAlias)); } // Invalidate the "all mappings" cache - keysToRemove.Add(AllMappingsKey); + keysToRemove.Add(CacheKeys.ModelMapping.AllMappings); var removed = await _cacheManager.RemoveManyAsync(keysToRemove, Region); diff --git a/Shared/ConduitLLM.Core/Services/CachedPricingRulesService.cs b/Shared/ConduitLLM.Core/Services/CachedPricingRulesService.cs index a16eeb617..7c4a66a85 100644 --- a/Shared/ConduitLLM.Core/Services/CachedPricingRulesService.cs +++ b/Shared/ConduitLLM.Core/Services/CachedPricingRulesService.cs @@ -1,25 +1,22 @@ using System.Text.Json; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; using ConduitLLM.Core.Models.Pricing; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace ConduitLLM.Core.Services; /// -/// Service for caching parsed pricing rules configurations with hybrid cache support (L1: Memory, L2: Redis). +/// Service for caching parsed pricing rules configurations using the CacheManager. /// Reduces JSON parsing overhead by maintaining parsed objects in cache. /// public class CachedPricingRulesService : ICachedPricingRulesService { - private readonly IMemoryCache _memoryCache; - private readonly IDistributedCache? _distributedCache; + private readonly ICacheManager _cacheManager; private readonly ILogger _logger; - private readonly TimeSpan _memoryCacheDuration = TimeSpan.FromMinutes(15); - private readonly TimeSpan _distributedCacheDuration = TimeSpan.FromHours(1); - private const string CacheKeyPrefix = "PricingRules_"; + private const CacheRegion Region = CacheRegion.PricingRules; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -31,17 +28,14 @@ public class CachedPricingRulesService : ICachedPricingRulesService /// /// Creates a new instance of the CachedPricingRulesService. /// - /// The memory cache for L1 caching. + /// The cache manager for L1/L2 caching. /// The logger. - /// Optional distributed cache for L2 caching (Redis). public CachedPricingRulesService( - IMemoryCache memoryCache, - ILogger logger, - IDistributedCache? distributedCache = null) + ICacheManager cacheManager, + ILogger logger) { - _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); + _cacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _distributedCache = distributedCache; } /// @@ -56,38 +50,22 @@ public CachedPricingRulesService( return null; } - var cacheKey = $"{CacheKeyPrefix}{modelCostId}"; + var cacheKey = CacheKeys.PricingRules.ById(modelCostId); - // Try L1 cache (memory) first - if (_memoryCache.TryGetValue(cacheKey, out PricingRulesConfig? memoryCached)) - { - _logger.LogDebug("Memory cache hit for pricing rules: ModelCostId={ModelCostId}", modelCostId); - return memoryCached; - } - - // Try L2 cache (distributed/Redis) if available - if (_distributedCache != null) + try { - try + // Try cache first + var cached = await _cacheManager.GetAsync(cacheKey, Region, cancellationToken); + if (cached != null) { - var distributedData = await _distributedCache.GetStringAsync(cacheKey, cancellationToken); - if (!string.IsNullOrEmpty(distributedData)) - { - var distributedConfig = JsonSerializer.Deserialize(distributedData, JsonOptions); - if (distributedConfig != null) - { - // Populate L1 cache for faster subsequent access - _memoryCache.Set(cacheKey, distributedConfig, _memoryCacheDuration); - _logger.LogDebug("Distributed cache hit for pricing rules: ModelCostId={ModelCostId}", modelCostId); - return distributedConfig; - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error retrieving pricing rules from distributed cache for ModelCostId={ModelCostId}", modelCostId); + _logger.LogDebug("Cache hit for pricing rules: ModelCostId={ModelCostId}", modelCostId); + return cached; } } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error retrieving pricing rules from cache for ModelCostId={ModelCostId}", modelCostId); + } // Cache miss - parse the configuration _logger.LogDebug("Cache miss for pricing rules: ModelCostId={ModelCostId}, parsing configuration", modelCostId); @@ -101,8 +79,15 @@ public CachedPricingRulesService( return null; } - // Store in both caches - await SetInCacheAsync(cacheKey, config, cancellationToken); + // Store in cache + try + { + await _cacheManager.SetAsync(cacheKey, config, Region, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error caching pricing rules for ModelCostId={ModelCostId}", modelCostId); + } _logger.LogDebug("Parsed and cached pricing rules for ModelCostId={ModelCostId}", modelCostId); return config; @@ -115,78 +100,32 @@ public CachedPricingRulesService( } /// - public void InvalidateCache(int modelCostId) + public async Task InvalidateCacheAsync(int modelCostId, CancellationToken cancellationToken = default) { - var cacheKey = $"{CacheKeyPrefix}{modelCostId}"; - - // Remove from memory cache - _memoryCache.Remove(cacheKey); + var cacheKey = CacheKeys.PricingRules.ById(modelCostId); - // Remove from distributed cache (fire-and-forget) - if (_distributedCache != null) + try { - Task.Run(async () => - { - try - { - await _distributedCache.RemoveAsync(cacheKey); - _logger.LogDebug("Invalidated distributed cache for pricing rules: ModelCostId={ModelCostId}", modelCostId); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error invalidating distributed cache for pricing rules: ModelCostId={ModelCostId}", modelCostId); - } - }); + await _cacheManager.RemoveAsync(cacheKey, Region, cancellationToken); + _logger.LogInformation("Invalidated pricing rules cache for ModelCostId={ModelCostId}", modelCostId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error invalidating pricing rules cache for ModelCostId={ModelCostId}", modelCostId); } - - _logger.LogInformation("Invalidated pricing rules cache for ModelCostId={ModelCostId}", modelCostId); } /// - public void InvalidateAll() + public async Task InvalidateAllAsync(CancellationToken cancellationToken = default) { - // For memory cache, we can't easily invalidate by prefix without tracking keys - // The recommended approach is to use cache entry options with a linked token - // For now, we'll compact the memory cache which forces eviction evaluation - if (_memoryCache is MemoryCache mc) + try { - mc.Compact(1.0); + await _cacheManager.ClearRegionAsync(Region, cancellationToken); + _logger.LogInformation("Invalidated all pricing rules cache entries"); } - - _logger.LogInformation("Invalidated all pricing rules cache entries (memory cache compacted)"); - - // Note: For distributed cache, we'd need Redis SCAN + DEL which is expensive - // Individual cache entries will expire naturally based on TTL - } - - /// - /// Sets a configuration in both L1 (memory) and L2 (distributed) caches. - /// - private async Task SetInCacheAsync(string cacheKey, PricingRulesConfig config, CancellationToken cancellationToken) - { - // Set in memory cache (L1) - _memoryCache.Set(cacheKey, config, _memoryCacheDuration); - - // Set in distributed cache (L2) if available - if (_distributedCache != null) + catch (Exception ex) { - try - { - var json = JsonSerializer.Serialize(config, JsonOptions); - await _distributedCache.SetStringAsync( - cacheKey, - json, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = _distributedCacheDuration - }, - cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error setting pricing rules in distributed cache for key={CacheKey}", cacheKey); - // Continue - memory cache is still populated - } + _logger.LogWarning(ex, "Error invalidating all pricing rules cache entries"); } } } diff --git a/Shared/ConduitLLM.Core/Services/CachedWebhookDeliveryTracker.cs b/Shared/ConduitLLM.Core/Services/CachedWebhookDeliveryTracker.cs index 25bdf0f48..3b9187636 100644 --- a/Shared/ConduitLLM.Core/Services/CachedWebhookDeliveryTracker.cs +++ b/Shared/ConduitLLM.Core/Services/CachedWebhookDeliveryTracker.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using ConduitLLM.Core.Constants; using ConduitLLM.Core.Interfaces; namespace ConduitLLM.Core.Services @@ -35,7 +36,7 @@ public CachedWebhookDeliveryTracker( public async Task IsDeliveredAsync(string deliveryKey) { - var cacheKey = $"webhook:delivered:{deliveryKey}"; + var cacheKey = RedisKeys.WebhookDelivery.Delivered(deliveryKey); // Check cache first if (_cache.TryGetValue(cacheKey, out var isDelivered)) @@ -61,7 +62,7 @@ public async Task MarkDeliveredAsync(string deliveryKey, string webhookUrl) await _innerTracker.MarkDeliveredAsync(deliveryKey, webhookUrl); // Update cache to indicate delivered - var cacheKey = $"webhook:delivered:{deliveryKey}"; + var cacheKey = RedisKeys.WebhookDelivery.Delivered(deliveryKey); _cache.Set(cacheKey, true, _cacheOptions); _logger.LogDebug("Marked webhook as delivered and cached: {DeliveryKey}", deliveryKey); diff --git a/Shared/ConduitLLM.Core/Services/ContextManager.cs b/Shared/ConduitLLM.Core/Services/ContextManager.cs index c622538f7..3d69b452d 100644 --- a/Shared/ConduitLLM.Core/Services/ContextManager.cs +++ b/Shared/ConduitLLM.Core/Services/ContextManager.cs @@ -93,7 +93,7 @@ public async Task ManageContextAsync(ChatCompletionReques } // Early exit conditions - if (maxContextTokens == null || maxContextTokens <= 0 || request.Messages == null || request.Messages.Count() == 0) + if (maxContextTokens == null || maxContextTokens <= 0 || request.Messages == null || !request.Messages.Any()) { return request; // Nothing to do if no limit or no messages } diff --git a/Shared/ConduitLLM.Core/Services/CostCalculationService.CacheSavings.cs b/Shared/ConduitLLM.Core/Services/CostCalculationService.CacheSavings.cs new file mode 100644 index 000000000..6ad573350 --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/CostCalculationService.CacheSavings.cs @@ -0,0 +1,65 @@ +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Core.Models; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Services; + +/// +/// Prompt cache savings calculation for the CostCalculationService. +/// +public partial class CostCalculationService +{ + /// + public async Task CalculateCacheSavingsAsync(string modelId, Usage usage, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(modelId) || usage == null) + return 0m; + + if (!HasCachedTokens(usage)) + return 0m; + + var modelCost = await _modelCostService.GetCostForModelAsync(modelId, cancellationToken); + return CalculateSavingsFromModelCost(modelCost, usage); + } + + /// + public async Task CalculateCacheSavingsByIdAsync(int modelCostId, Usage usage, CancellationToken cancellationToken = default) + { + if (usage == null) + return 0m; + + if (!HasCachedTokens(usage)) + return 0m; + + var modelCost = await _modelCostService.GetCostByIdAsync(modelCostId, cancellationToken); + return CalculateSavingsFromModelCost(modelCost, usage); + } + + private static bool HasCachedTokens(Usage usage) + => usage.CachedInputTokens.HasValue && usage.CachedInputTokens.Value > 0; + + private decimal CalculateSavingsFromModelCost(ModelCost? modelCost, Usage usage) + { + if (modelCost == null) + return 0m; + + // Savings from cached reads: tokens that were charged at cached rate instead of full rate + decimal savings = 0m; + + if (usage.CachedInputTokens.HasValue && usage.CachedInputTokens.Value > 0 + && modelCost.CachedInputCostPerMillionTokens.HasValue) + { + var fullCost = usage.CachedInputTokens.Value * modelCost.InputCostPerMillionTokens / 1_000_000m; + var cachedCost = usage.CachedInputTokens.Value * modelCost.CachedInputCostPerMillionTokens.Value / 1_000_000m; + savings = fullCost - cachedCost; + } + + if (savings > 0) + { + _logger.LogDebug("Prompt caching savings for model: {CachedTokens} cached tokens saved ${Savings:F6}", + usage.CachedInputTokens, savings); + } + + return Math.Max(0m, savings); + } +} diff --git a/Shared/ConduitLLM.Core/Services/CostCalculationService.PricingModels.cs b/Shared/ConduitLLM.Core/Services/CostCalculationService.PricingModels.cs index 20a5662c8..5c44f27c1 100644 --- a/Shared/ConduitLLM.Core/Services/CostCalculationService.PricingModels.cs +++ b/Shared/ConduitLLM.Core/Services/CostCalculationService.PricingModels.cs @@ -36,7 +36,7 @@ private Task CalculatePerVideoCostAsync(string modelId, ModelCost model } } - if (config == null || config.Rates == null || config.Rates.Count() == 0) + if (config == null || config.Rates == null || !config.Rates.Any()) { _logger.LogError("No per-video pricing rates configured for model {ModelId}", modelId); throw new InvalidOperationException($"No per-video pricing rates configured for model {modelId}"); @@ -161,7 +161,7 @@ private Task CalculateTieredTokensCostAsync(string modelId, ModelCost m } } - if (config == null || config.Tiers == null || config.Tiers.Count() == 0) + if (config == null || config.Tiers == null || !config.Tiers.Any()) { _logger.LogError("No tiered tokens pricing configuration for model {ModelId}", modelId); throw new InvalidOperationException($"No tiered tokens pricing configuration for model {modelId}"); diff --git a/Shared/ConduitLLM.Core/Services/CostCalculationService.Refunds.cs b/Shared/ConduitLLM.Core/Services/CostCalculationService.Refunds.cs index 87ca14730..0ce551742 100644 --- a/Shared/ConduitLLM.Core/Services/CostCalculationService.Refunds.cs +++ b/Shared/ConduitLLM.Core/Services/CostCalculationService.Refunds.cs @@ -70,7 +70,7 @@ public async Task CalculateRefundAsync( // Validate refund amounts don't exceed original amounts var validationMessages = ValidateRefundAmounts(originalUsage, refundUsage); - if (validationMessages.Count() > 0) + if (validationMessages.Any()) { result.ValidationMessages.AddRange(validationMessages); result.IsPartialRefund = true; diff --git a/Shared/ConduitLLM.Core/Services/DatabaseModelCapabilityService.cs b/Shared/ConduitLLM.Core/Services/DatabaseModelCapabilityService.cs index 93aeffa44..eccaed17c 100644 --- a/Shared/ConduitLLM.Core/Services/DatabaseModelCapabilityService.cs +++ b/Shared/ConduitLLM.Core/Services/DatabaseModelCapabilityService.cs @@ -1,13 +1,13 @@ using System.Text.Json; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Interfaces; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; - -using ConduitLLM.Configuration.Interfaces; namespace ConduitLLM.Core.Services { /// @@ -253,7 +253,8 @@ private async Task SetInHybridCacheAsync(string key, T value) if (mapping == null) { // Try to find by provider model name - var allMappings = await _repository.GetAllAsync(cancellationToken); + var allMappings = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _repository.GetPaginatedAsync, cancellationToken: cancellationToken); mapping = allMappings.FirstOrDefault(m => m.ProviderModelId.Equals(model, StringComparison.OrdinalIgnoreCase)); } diff --git a/Shared/ConduitLLM.Core/Services/DiscoveryCacheService.cs b/Shared/ConduitLLM.Core/Services/DiscoveryCacheService.cs index 1b7977d4f..4a70216ea 100644 --- a/Shared/ConduitLLM.Core/Services/DiscoveryCacheService.cs +++ b/Shared/ConduitLLM.Core/Services/DiscoveryCacheService.cs @@ -21,9 +21,8 @@ public class DiscoveryCacheOptions /// /// Whether caching is enabled - /// TEMPORARILY DISABLED: Serialization issue with anonymous objects in DiscoveryModelsResult.Data /// - public bool EnableCaching { get; set; } = false; // TODO: Re-enable after fixing serialization + public bool EnableCaching { get; set; } = true; /// /// Whether to warm cache on startup diff --git a/Shared/ConduitLLM.Core/Services/DistributedCachePopulator.cs b/Shared/ConduitLLM.Core/Services/DistributedCachePopulator.cs new file mode 100644 index 000000000..8e3d965bc --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/DistributedCachePopulator.cs @@ -0,0 +1,169 @@ +using System.Collections.Concurrent; +using ConduitLLM.Core.Interfaces; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Services +{ + /// + /// Implements cache stampede prevention using hybrid local + distributed locking. + /// When multiple requests hit a cache miss simultaneously, only one performs the + /// database query while others wait for the result. + /// + public class DistributedCachePopulator : IDistributedCachePopulator + { + private readonly IDistributedLockService _lockService; + private readonly ILogger _logger; + + // Local locks prevent same-instance stampedes (faster than distributed locks) + private readonly ConcurrentDictionary _localLocks = new(); + + // Configuration + private static readonly TimeSpan LockExpiry = TimeSpan.FromSeconds(30); + private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + public DistributedCachePopulator( + IDistributedLockService lockService, + ILogger logger) + { + _lockService = lockService; + _logger = logger; + } + + /// + public async Task GetOrPopulateAsync( + string lockKey, + Func> cacheCheck, + Func> factory, + CancellationToken cancellationToken = default) where T : class + { + // Step 1: Fast path - check cache without any locking + try + { + var cachedValue = await cacheCheck(); + if (cachedValue != null) + { + return cachedValue; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache check failed for key {LockKey}, proceeding to population", lockKey); + } + + // Step 2: Acquire local lock to prevent same-instance stampede + var localLock = _localLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); + + try + { + // Wait for local lock with timeout + if (!await localLock.WaitAsync(LockTimeout, cancellationToken)) + { + _logger.LogWarning("Timeout waiting for local lock on {LockKey}, falling back to factory", lockKey); + return await factory(); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error acquiring local lock for {LockKey}, falling back to factory", lockKey); + return await factory(); + } + + try + { + // Step 3: Double-check cache after acquiring local lock + try + { + var cachedValue = await cacheCheck(); + if (cachedValue != null) + { + _logger.LogDebug("Cache hit after local lock for {LockKey}", lockKey); + return cachedValue; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache double-check failed for {LockKey}", lockKey); + } + + // Step 4: Acquire distributed lock to prevent cross-instance stampede + IDistributedLock? distributedLock = null; + try + { + distributedLock = await _lockService.AcquireLockWithRetryAsync( + lockKey, + LockExpiry, + LockTimeout, + RetryDelay, + cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to acquire distributed lock for {LockKey}, proceeding without it", lockKey); + // Proceed without distributed lock - local lock still provides some protection + } + + try + { + // Step 5: Triple-check cache after acquiring distributed lock + // Another instance may have populated it while we were waiting + if (distributedLock != null) + { + try + { + var cachedValue = await cacheCheck(); + if (cachedValue != null) + { + _logger.LogDebug("Cache hit after distributed lock for {LockKey}", lockKey); + return cachedValue; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache triple-check failed for {LockKey}", lockKey); + } + } + + // Step 6: Call factory (database fallback) + _logger.LogDebug("Executing factory for {LockKey}", lockKey); + return await factory(); + } + finally + { + // Step 7: Release distributed lock + if (distributedLock != null) + { + try + { + await distributedLock.ReleaseAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error releasing distributed lock for {LockKey}", lockKey); + } + } + } + } + finally + { + // Release local lock + localLock.Release(); + + // Cleanup: Remove semaphore from dictionary if no one is waiting + // This prevents memory leaks from accumulating semaphores + if (localLock.CurrentCount == 1) + { + _localLocks.TryRemove(lockKey, out _); + } + } + } + } +} diff --git a/Shared/ConduitLLM.Core/Services/EphemeralKeyServiceBase.cs b/Shared/ConduitLLM.Core/Services/EphemeralKeyServiceBase.cs new file mode 100644 index 000000000..0a432fc20 --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/EphemeralKeyServiceBase.cs @@ -0,0 +1,273 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Services +{ + /// + /// Abstract base class for ephemeral key services that provides common cache operations + /// and key management functionality. + /// + /// The type of key data stored in cache + public abstract class EphemeralKeyServiceBase where TKeyData : class + { + protected readonly IDistributedCache Cache; + protected readonly ILogger Logger; + + /// + /// The prefix used for cache keys (e.g., "ephemeral:" or "ephemeral:master:") + /// + protected abstract string KeyPrefix { get; } + + /// + /// The prefix added to generated tokens (e.g., "ek_" or "emk_") + /// + protected abstract string TokenPrefix { get; } + + /// + /// The TTL in seconds for ephemeral keys + /// + protected abstract int TTLSeconds { get; } + + /// + /// Initializes a new instance of the ephemeral key service base + /// + /// The distributed cache + /// The logger + protected EphemeralKeyServiceBase(IDistributedCache cache, ILogger logger) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Generates a cryptographically secure token with the configured prefix + /// + /// A secure, URL-safe token + protected string GenerateSecureToken() + { + const int tokenLength = 32; // 256 bits + var randomBytes = new byte[tokenLength]; + + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randomBytes); + } + + // Convert to URL-safe base64 + var token = Convert.ToBase64String(randomBytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + + return $"{TokenPrefix}{token}"; + } + + /// + /// Sanitizes a key for safe logging by truncating it + /// + /// The key to sanitize + /// A truncated version of the key safe for logging + protected static string SanitizeKeyForLogging(string key) + { + if (string.IsNullOrEmpty(key)) + return "[empty]"; + + if (key.Length <= 10) + return key; + + return $"{key.Substring(0, 10)}..."; + } + + /// + /// Gets the full cache key for a given ephemeral key token + /// + /// The ephemeral key token + /// The full cache key + protected string GetCacheKey(string key) => $"{KeyPrefix}{key}"; + + /// + /// Stores key data in the distributed cache with the configured TTL + /// + /// The ephemeral key token + /// The key data to store + /// Optional TTL override in seconds + protected async Task StoreKeyDataAsync(string key, TKeyData keyData, int? ttlOverride = null) + { + var cacheKey = GetCacheKey(key); + var serializedData = JsonSerializer.Serialize(keyData); + var ttl = ttlOverride ?? TTLSeconds; + + await Cache.SetStringAsync( + cacheKey, + serializedData, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(ttl) + }); + } + + /// + /// Retrieves key data from the distributed cache + /// + /// The ephemeral key token + /// The key data if found, null otherwise + protected async Task GetKeyDataFromCacheAsync(string key) + { + var cacheKey = GetCacheKey(key); + var serializedData = await Cache.GetStringAsync(cacheKey); + + if (string.IsNullOrEmpty(serializedData)) + { + return null; + } + + return JsonSerializer.Deserialize(serializedData); + } + + /// + /// Deletes an ephemeral key from the cache + /// + /// The ephemeral key to delete + public virtual async Task DeleteKeyAsync(string key) + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + var cacheKey = GetCacheKey(key); + await Cache.RemoveAsync(cacheKey); + + Logger.LogDebug("Deleted ephemeral key: {Key}", SanitizeKeyForLogging(key)); + } + + /// + /// Checks if a key exists in the cache + /// + /// The ephemeral key to check + /// True if the key exists, false otherwise + public virtual async Task KeyExistsAsync(string key) + { + if (string.IsNullOrEmpty(key)) + { + return false; + } + + var cacheKey = GetCacheKey(key); + var data = await Cache.GetStringAsync(cacheKey); + return !string.IsNullOrEmpty(data); + } + + /// + /// Checks if the key data indicates the key has been consumed + /// + /// The key data to check + /// True if the key has been consumed + protected abstract bool IsKeyConsumed(TKeyData keyData); + + /// + /// Gets the expiration time from the key data + /// + /// The key data + /// The expiration time + protected abstract DateTimeOffset GetKeyExpiration(TKeyData keyData); + + /// + /// Checks if the key data indicates the key is valid (beyond just not-consumed and not-expired) + /// + /// The key data to check + /// True if the key is valid + protected virtual bool IsKeyValid(TKeyData keyData) => true; + + /// + /// Marks the key data as consumed + /// + /// The key data to mark + protected abstract void MarkKeyAsConsumed(TKeyData keyData); + + /// + /// Validates key data without consuming it + /// + /// The ephemeral key to validate + /// The key data if valid, null otherwise + protected async Task ValidateKeyAsync(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + Logger.LogDebug("Ephemeral key validation failed: empty or whitespace key"); + return null; + } + + var keyData = await GetKeyDataFromCacheAsync(key); + if (keyData == null) + { + Logger.LogWarning("Ephemeral key not found: {Key}", SanitizeKeyForLogging(key)); + return null; + } + + if (IsKeyConsumed(keyData)) + { + Logger.LogWarning("Ephemeral key already used: {Key}", SanitizeKeyForLogging(key)); + return null; + } + + var expiresAt = GetKeyExpiration(keyData); + if (expiresAt < DateTimeOffset.UtcNow) + { + Logger.LogWarning("Ephemeral key expired: {Key}, expired at {ExpiresAt}", + SanitizeKeyForLogging(key), expiresAt); + await Cache.RemoveAsync(GetCacheKey(key)); + return null; + } + + if (!IsKeyValid(keyData)) + { + Logger.LogWarning("Ephemeral key is not valid: {Key}", SanitizeKeyForLogging(key)); + return null; + } + + return keyData; + } + + /// + /// Validates a key and marks it as consumed, keeping it briefly in cache for cleanup tracking + /// + /// The ephemeral key to validate and consume + /// The key data if valid and successfully consumed, null otherwise + protected async Task ValidateAndConsumeKeyInternalAsync(string key) + { + var keyData = await ValidateKeyAsync(key); + if (keyData == null) + { + return null; + } + + // Mark as consumed but keep in cache for cleanup tracking + MarkKeyAsConsumed(keyData); + await StoreKeyDataAsync(key, keyData, ttlOverride: 30); // Keep for 30s for cleanup + + return keyData; + } + + /// + /// Validates a key and immediately deletes it (for streaming scenarios) + /// + /// The ephemeral key to consume + /// The key data if valid, null otherwise + protected async Task ConsumeKeyInternalAsync(string key) + { + var keyData = await ValidateKeyAsync(key); + if (keyData == null) + { + return null; + } + + // For streaming, immediately delete the key after successful validation + await Cache.RemoveAsync(GetCacheKey(key)); + + return keyData; + } + } +} diff --git a/Shared/ConduitLLM.Core/Services/EventPublishingServiceBase.cs b/Shared/ConduitLLM.Core/Services/EventPublishingServiceBase.cs index 3d838204f..e706f4cd8 100644 --- a/Shared/ConduitLLM.Core/Services/EventPublishingServiceBase.cs +++ b/Shared/ConduitLLM.Core/Services/EventPublishingServiceBase.cs @@ -13,6 +13,11 @@ public abstract class EventPublishingServiceBase private readonly IPublishEndpoint? _publishEndpoint; private readonly ILogger _logger; + /// + /// Gets the logger instance for use by derived classes. + /// + protected ILogger Logger => _logger; + /// /// Initializes a new instance of the class. /// diff --git a/Shared/ConduitLLM.Core/Services/FileRetrievalService.cs b/Shared/ConduitLLM.Core/Services/FileRetrievalService.cs index 7c716cfcb..1eb103e03 100644 --- a/Shared/ConduitLLM.Core/Services/FileRetrievalService.cs +++ b/Shared/ConduitLLM.Core/Services/FileRetrievalService.cs @@ -6,22 +6,30 @@ namespace ConduitLLM.Core.Services /// /// Service for retrieving and downloading generated content files. /// + /// + /// This service uses a typed HttpClient configured with retry policies for resilience + /// when fetching files from external URLs. The retry policy handles transient HTTP errors + /// and rate limiting (HTTP 429) with exponential backoff. + /// public class FileRetrievalService : IFileRetrievalService { private readonly IMediaStorageService _storageService; - private readonly IHttpClientFactory _httpClientFactory; + private readonly HttpClient _httpClient; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// + /// The media storage service for local storage operations. + /// The typed HTTP client configured with retry policies. + /// The logger instance. public FileRetrievalService( IMediaStorageService storageService, - IHttpClientFactory httpClientFactory, + HttpClient httpClient, ILogger logger) { _storageService = storageService ?? throw new ArgumentNullException(nameof(storageService)); - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -224,57 +232,64 @@ private bool IsUrl(string identifier) private async Task RetrieveFromUrlAsync(string url, CancellationToken cancellationToken) { - var httpClient = _httpClientFactory.CreateClient(); - - var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - if (!response.IsSuccessStatusCode) + HttpResponseMessage? response = null; + try { - _logger.LogWarning("Failed to retrieve URL {Url}: {StatusCode}", url, response.StatusCode); - response.Dispose(); - return null; - } + response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to retrieve URL {Url}: {StatusCode}", url, response.StatusCode); + return null; + } - var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; - var contentLength = response.Content.Headers.ContentLength ?? 0; - var lastModified = response.Content.Headers.LastModified?.DateTime; - var etag = response.Headers.ETag?.Tag; + var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; + var contentLength = response.Content.Headers.ContentLength ?? 0; + var lastModified = response.Content.Headers.LastModified?.DateTime; + var etag = response.Headers.ETag?.Tag; - // Extract filename from Content-Disposition header if available - string? fileName = null; - if (response.Content.Headers.ContentDisposition?.FileName != null) - { - fileName = response.Content.Headers.ContentDisposition.FileName.Trim('"'); - } - else - { - // Try to extract from URL - fileName = Path.GetFileName(new Uri(url).LocalPath); - } + // Extract filename from Content-Disposition header if available + string? fileName = null; + if (response.Content.Headers.ContentDisposition?.FileName != null) + { + fileName = response.Content.Headers.ContentDisposition.FileName.Trim('"'); + } + else + { + // Try to extract from URL + fileName = Path.GetFileName(new Uri(url).LocalPath); + } - var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - return new FileRetrievalResult - { - ContentStream = stream, - Metadata = new FileMetadata + var result = new FileRetrievalResult { - FileName = fileName, - ContentType = contentType, - SizeBytes = contentLength, - ModifiedAt = lastModified, - StorageProvider = "url", - ETag = etag, - SupportsRangeRequests = response.Headers.AcceptRanges?.Contains("bytes") == true - } - }; + ContentStream = stream, + Metadata = new FileMetadata + { + FileName = fileName, + ContentType = contentType, + SizeBytes = contentLength, + ModifiedAt = lastModified, + StorageProvider = "url", + ETag = etag, + SupportsRangeRequests = response.Headers.AcceptRanges?.Contains("bytes") == true + }, + // Transfer ownership: result.Dispose() will dispose both the stream and the response. + Owner = response + }; + response = null; + return result; + } + finally + { + response?.Dispose(); + } } private async Task GetUrlMetadataAsync(string url, CancellationToken cancellationToken) { - var httpClient = _httpClientFactory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Head, url); - using var response = await httpClient.SendAsync(request, cancellationToken); + using var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -310,16 +325,15 @@ private bool IsUrl(string identifier) private async Task CheckUrlExistsAsync(string url, CancellationToken cancellationToken) { - var httpClient = _httpClientFactory.CreateClient(); - try { using var request = new HttpRequestMessage(HttpMethod.Head, url); - using var response = await httpClient.SendAsync(request, cancellationToken); + using var response = await _httpClient.SendAsync(request, cancellationToken); return response.IsSuccessStatusCode; } - catch + catch (Exception ex) { + _logger.LogDebug(ex, "URL existence check failed for {Url}", url); return false; } } diff --git a/Shared/ConduitLLM.Core/Services/HybridAsyncTaskService.Advanced.cs b/Shared/ConduitLLM.Core/Services/HybridAsyncTaskService.Advanced.cs index 261aecc77..e8cc0b185 100644 --- a/Shared/ConduitLLM.Core/Services/HybridAsyncTaskService.Advanced.cs +++ b/Shared/ConduitLLM.Core/Services/HybridAsyncTaskService.Advanced.cs @@ -53,7 +53,7 @@ public async Task CleanupOldTasksAsync(TimeSpan olderThan, CancellationToke var cleanupThreshold = TimeSpan.FromDays(30); // Keep archived tasks for 30 days var tasksToDelete = await _repository.GetTasksForCleanupAsync(cleanupThreshold, 100, cancellationToken); - if (tasksToDelete.Count() > 0) + if (tasksToDelete.Any()) { var taskIds = tasksToDelete.Select(t => t.Id); var deletedCount = await _repository.BulkDeleteAsync(taskIds, cancellationToken); diff --git a/Shared/ConduitLLM.Core/Services/ImageDownloadService.cs b/Shared/ConduitLLM.Core/Services/ImageDownloadService.cs new file mode 100644 index 000000000..04891cc21 --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/ImageDownloadService.cs @@ -0,0 +1,131 @@ +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using ConduitLLM.Core.Utilities; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Core.Services; + +/// +/// Service for downloading images from external URLs using IHttpClientFactory for proper connection management. +/// This service prevents socket exhaustion under high load by using pooled HTTP connections. +/// +public class ImageDownloadService : IImageDownloadService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + /// + /// The name of the named HttpClient used for external image fetching. + /// + public const string HttpClientName = "ExternalImageFetch"; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client factory for creating managed HttpClient instances. + /// The logger for diagnostic output. + public ImageDownloadService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task DownloadImageAsync(string url, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentException("URL cannot be null or empty", nameof(url)); + } + + // Handle data URLs directly + if (url.StartsWith("data:")) + { + byte[]? imageData = ImageUtility.ExtractImageDataFromDataUrl(url, out _); + if (imageData == null) + { + throw new ArgumentException("Invalid data URL format", nameof(url)); + } + return imageData; + } + + var httpClient = _httpClientFactory.CreateClient(HttpClientName); + + try + { + _logger.LogDebug("Downloading image from {Url}", url); + return await httpClient.GetByteArrayAsync(url, cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to download image from {Url}", url); + throw new IOException($"Failed to download image from URL: {ex.Message}", ex); + } + } + + /// + public async Task DownloadAsImageUrlAsync(string url, string? detail = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(url)) + { + throw new ArgumentException("URL cannot be null or empty", nameof(url)); + } + + // If already a data URL, just return it wrapped + if (url.StartsWith("data:")) + { + return new ImageUrl { Url = url, Detail = detail }; + } + + byte[] imageBytes = await DownloadImageAsync(url, cancellationToken); + + // Detect MIME type from image bytes + string mimeType = DetectMimeType(imageBytes); + + string dataUrl = $"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}"; + + return new ImageUrl + { + Url = dataUrl, + Detail = detail + }; + } + + /// + /// Detects the MIME type from image bytes by examining magic numbers. + /// + /// The image data bytes. + /// The detected MIME type, or "image/jpeg" as fallback. + private static string DetectMimeType(byte[] imageBytes) + { + // Use ImageUtility's detection if available, otherwise fall back to inline detection + string? detectedType = ImageUtility.DetectMimeType(imageBytes); + if (detectedType != null) + { + return detectedType; + } + + // Fallback detection using magic numbers + if (imageBytes.Length >= 2) + { + if (imageBytes[0] == 0xFF && imageBytes[1] == 0xD8) + return "image/jpeg"; + + if (imageBytes.Length >= 8 && + imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && + imageBytes[2] == 0x4E && imageBytes[3] == 0x47) + return "image/png"; + + if (imageBytes.Length >= 3 && + imageBytes[0] == 0x47 && imageBytes[1] == 0x49 && + imageBytes[2] == 0x46) + return "image/gif"; + + if (imageBytes[0] == 0x42 && imageBytes[1] == 0x4D) + return "image/bmp"; + } + + return "image/jpeg"; // Default fallback + } +} diff --git a/Shared/ConduitLLM.Core/Services/ImageGenerationOrchestrator.cs b/Shared/ConduitLLM.Core/Services/ImageGenerationOrchestrator.cs index 1ce041e24..669ff4328 100644 --- a/Shared/ConduitLLM.Core/Services/ImageGenerationOrchestrator.cs +++ b/Shared/ConduitLLM.Core/Services/ImageGenerationOrchestrator.cs @@ -54,10 +54,12 @@ public ImageGenerationOrchestrator( IHttpClientFactory httpClientFactory, MinimalParameterValidator parameterValidator, MediaGenerationMetrics metrics, + IProviderErrorTrackingService errorTrackingService, ILogger logger) : base(clientFactory, taskService, storageService, publishEndpoint, modelMappingService, virtualKeyService, costService, taskRegistry, - webhookService, httpClientFactory, parameterValidator, metrics, logger) + webhookService, httpClientFactory, parameterValidator, metrics, + errorTrackingService, logger) { // Initialize processing strategies @@ -81,7 +83,7 @@ protected override bool ShouldProcessRequest(ImageGenerationRequested request) CancellationToken cancellationToken) { // Get the client for the model - var client = _clientFactory.GetClient(modelInfo.ModelId); + var client = await _clientFactory.GetClientAsync(modelInfo.ModelId, cancellationToken); // Generate images return await client.CreateImageAsync(request, cancellationToken: cancellationToken); diff --git a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Failover.cs b/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Failover.cs deleted file mode 100644 index 22420d211..000000000 --- a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Failover.cs +++ /dev/null @@ -1,231 +0,0 @@ -using ConduitLLM.Configuration.Interfaces; -using ConduitLLM.Core.Interfaces; - -using MassTransit; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Core.Services -{ - /// - /// Provides self-healing and automatic failover capabilities for image generation - Failover functionality - /// - public partial class ImageGenerationResilienceService - { - private async Task HandleUnhealthyProviderAsync( - int providerId, - ProviderHealthState state, - ProviderStatus status) - { - var providerName = GetProviderName(providerId); - _logger.LogWarning( - "Provider {ProviderId} ({ProviderName}) is unhealthy - Score: {Score}, Failures: {Failures}", - providerId, providerName, status.HealthScore, status.ConsecutiveFailures); - - // Check if already quarantined - if (!state.IsQuarantined) - { - // Quarantine the provider - await QuarantineProviderAsync(providerId, state, $"Health score: {status.HealthScore:F2}, Consecutive failures: {status.ConsecutiveFailures}"); - - // Initiate failover if primary provider - if (IsPrimaryProvider(providerId)) - { - await InitiateFailoverAsync(providerId, status); - } - } - - // Update recovery attempts - _recoveryAttempts.AddOrUpdate(providerId, - new RecoveryAttempt { ProviderId = providerId, AttemptCount = 1 }, - (_, attempt) => { attempt.AttemptCount++; return attempt; }); - } - - private async Task QuarantineProviderAsync(int providerId, ProviderHealthState state, string reason) - { - state.IsQuarantined = true; - state.QuarantinedAt = DateTime.UtcNow; - state.QuarantineReason = reason; - - var providerName = GetProviderName(providerId); - _logger.LogWarning("Quarantined provider {ProviderId} ({ProviderName}): {Reason}", providerId, providerName, reason); - - // Update provider configuration to disable it - using var scope = _serviceProvider.CreateScope(); - var mappingService = scope.ServiceProvider.GetService(); - - if (mappingService != null) - { - try - { - // Get all mappings for this provider - var allMappings = await mappingService.GetAllMappingsAsync(); - - // Get provider service to load provider information - var providerService = scope.ServiceProvider.GetService(); - if (providerService == null) - { - _logger.LogWarning("IProviderService not available, cannot quarantine provider"); - return; - } - - // Load all providers to match by ProviderType - var allProviders = await providerService.GetAllProvidersAsync(); - var providersByType = allProviders - .Where(p => p.ProviderType.ToString() == providerName) - .Select(p => p.Id) - .ToList(); - - var providerMappings = allMappings - .Where(m => providersByType.Contains(m.ProviderId)) - .ToList(); - - // TODO: Implement mapping updates when the service supports it - // foreach (var mapping in providerMappings) - // { - // mapping.IsEnabled = false; - // await mappingService.UpdateMappingAsync(mapping); - // } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to disable mappings for quarantined provider {Provider}", providerName); - } - } - - // Publish quarantine event - if (_publishEndpoint != null) - { - await _publishEndpoint.Publish(new ProviderQuarantined - { - ProviderId = providerId, - ProviderName = GetProviderName(providerId), - Reason = reason, - QuarantinedAt = state.QuarantinedAt.Value, - CorrelationId = Guid.NewGuid().ToString() - }); - } - } - - private async Task InitiateFailoverAsync(int failedProviderId, ProviderStatus status) - { - var failedProviderName = GetProviderName(failedProviderId); - _logger.LogInformation("Initiating failover from provider {ProviderId} ({ProviderName})", failedProviderId, failedProviderName); - - var failoverState = new FailoverState - { - FailedProviderId = failedProviderId, - InitiatedAt = DateTime.UtcNow, - Reason = $"Provider unhealthy: {status.LastError}" - }; - - // Find alternative providers - using var scope = _serviceProvider.CreateScope(); - var mappingService = scope.ServiceProvider.GetService(); - - if (mappingService != null) - { - var allMappings = await mappingService.GetAllMappingsAsync(); - - // Get provider service to load provider information - var providerService = scope.ServiceProvider.GetService(); - if (providerService == null) - { - _logger.LogWarning("IProviderService not available, cannot initiate failover"); - return; - } - - // Load all providers - var allProviders = await providerService.GetAllProvidersAsync(); - var providerLookup = allProviders.ToDictionary(p => p.Id, p => p); - - // Find image generation mappings not from the failed provider - var imageProviders = allMappings - .Where(m => m.ModelProviderTypeAssociation?.Model?.SupportsImageGeneration == true && m.IsEnabled && m.ProviderId != failedProviderId) - .GroupBy(m => m.ProviderId) - .ToList(); - - // Select best alternative based on health scores - int? selectedProviderId = null; - double bestScore = 0; - - foreach (var providerGroup in imageProviders) - { - var providerId = providerGroup.Key; - if (_providerStates.TryGetValue(providerId, out var state) && - state.IsHealthy && - state.HealthScore > bestScore) - { - selectedProviderId = providerId; - bestScore = state.HealthScore; - } - } - - if (selectedProviderId.HasValue) - { - failoverState.FailoverProviderId = selectedProviderId.Value; - failoverState.Status = FailoverStatus.Active; - - var selectedProviderName = GetProviderName(selectedProviderId.Value); - _logger.LogInformation( - "Failover initiated: {FailedId} ({FailedName}) -> {FailoverId} ({FailoverName})", - failedProviderId, failedProviderName, selectedProviderId.Value, selectedProviderName); - - // Update failover configuration - await UpdateFailoverConfigurationAsync(failedProviderId, selectedProviderId.Value); - } - else - { - failoverState.Status = FailoverStatus.NoAlternative; - _logger.LogError("No healthy alternative providers available for failover"); - } - } - - _failoverStates[failedProviderId] = failoverState; - } - - private async Task UpdateFailoverConfigurationAsync(int failedProviderId, int failoverProviderId) - { - // This would update routing configuration to redirect traffic - // In a real implementation, this might update a configuration service - // or publish events that the routing layer would consume - - if (_publishEndpoint != null) - { - await _publishEndpoint.Publish(new ProviderFailoverInitiated - { - FailedProviderId = failedProviderId, - FailedProviderName = GetProviderName(failedProviderId), - FailoverProviderId = failoverProviderId, - FailoverProviderName = GetProviderName(failoverProviderId), - InitiatedAt = DateTime.UtcNow, - CorrelationId = Guid.NewGuid().ToString() - }); - } - } - - private async Task HandleSlowProviderAsync(int providerId, ProviderStatus status) - { - var providerName = GetProviderName(providerId); - _logger.LogWarning( - "Provider {ProviderId} ({ProviderName}) experiencing slow response times: {ResponseTime}ms", - providerId, providerName, status.AverageResponseTimeMs); - - // Reduce load on slow provider - var state = _providerStates[providerId]; - if (!state.IsThrottled) - { - state.IsThrottled = true; - state.ThrottleLevel = 0.5; // Reduce to 50% traffic - - _logger.LogInformation( - "Throttling provider {ProviderId} ({ProviderName}) to {Level:P0} traffic", - providerId, providerName, state.ThrottleLevel); - - // Update provider weight in load balancing - await UpdateProviderWeightAsync(providerId, state.ThrottleLevel); - } - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Recovery.cs b/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Recovery.cs deleted file mode 100644 index bd90fc60b..000000000 --- a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Recovery.cs +++ /dev/null @@ -1,315 +0,0 @@ -using ConduitLLM.Configuration.Interfaces; -using ConduitLLM.Core.Interfaces; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Core.Services -{ - /// - /// Provides self-healing and automatic failover capabilities for image generation - Recovery functionality - /// - public partial class ImageGenerationResilienceService - { - private async Task PerformRecoveryChecksAsync(CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - return; - - try - { - // Check quarantined providers for recovery - var quarantinedProviders = _providerStates - .Where(p => p.Value.IsQuarantined) - .ToList(); - - foreach (var (providerId, state) in quarantinedProviders) - { - await CheckProviderRecoveryAsync(providerId, state); - } - - // Check active failovers - var activeFailovers = _failoverStates - .Where(f => f.Value.Status == FailoverStatus.Active) - .ToList(); - - foreach (var (originalProviderId, failoverState) in activeFailovers) - { - await CheckFailoverRecoveryAsync(originalProviderId, failoverState); - } - - // Perform self-healing actions - await PerformSelfHealingAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error performing recovery checks"); - } - } - - private async Task CheckProviderRecoveryAsync(int providerId, ProviderHealthState state) - { - if (!state.QuarantinedAt.HasValue) - return; - - var quarantineDuration = DateTime.UtcNow - state.QuarantinedAt.Value; - - // Check if minimum quarantine time has passed - if (quarantineDuration < _options.MinimumQuarantineTime) - return; - - var providerName = GetProviderName(providerId); - _logger.LogInformation("Checking recovery for quarantined provider {ProviderId} ({ProviderName})", providerId, providerName); - - // Perform health probe - var isHealthy = await ProbeProviderHealthAsync(providerId); - - if (isHealthy) - { - await AttemptProviderRecoveryAsync(providerId, state); - } - else if (quarantineDuration > _options.MaximumQuarantineTime) - { - _logger.LogError( - "Provider {ProviderId} ({ProviderName}) exceeded maximum quarantine time without recovery", - providerId, providerName); - - // Mark as permanently failed - state.IsPermanentlyFailed = true; - } - } - - private Task ProbeProviderHealthAsync(int providerId) - { - try - { - using var scope = _serviceProvider.CreateScope(); - - // Perform a lightweight health check - // This would typically make a simple API call to verify the provider is responsive - - return Task.FromResult(true); // Simplified for this implementation - } - catch (Exception ex) - { - _logger.LogError(ex, "Health probe failed for provider {ProviderId}", providerId); - return Task.FromResult(false); - } - } - - private async Task AttemptProviderRecoveryAsync(int providerId, ProviderHealthState state) - { - var providerName = GetProviderName(providerId); - _logger.LogInformation("Attempting recovery for provider {ProviderId} ({ProviderName})", providerId, providerName); - - // Re-enable provider with limited traffic - state.IsQuarantined = false; - state.IsThrottled = true; - state.ThrottleLevel = 0.1; // Start with 10% traffic - state.RecoveryStarted = DateTime.UtcNow; - - // Re-enable provider mappings - using var scope = _serviceProvider.CreateScope(); - var mappingService = scope.ServiceProvider.GetService(); - - if (mappingService != null) - { - try - { - // Get all mappings for this specific provider ID - var allMappings = await mappingService.GetAllMappingsAsync(); - var providerMappings = allMappings - .Where(m => m.ProviderId == providerId) - .ToList(); - - // TODO: Implement mapping updates when the service supports it - // foreach (var mapping in providerMappings) - // { - // mapping.IsEnabled = true; - // await mappingService.UpdateMappingAsync(mapping); - // } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to re-enable mappings for provider {ProviderId}", providerId); - } - } - - // Update provider weight for gradual recovery - await UpdateProviderWeightAsync(providerId, state.ThrottleLevel); - - // Publish recovery event - if (_publishEndpoint != null) - { - await _publishEndpoint.Publish(new ProviderRecoveryInitiated - { - ProviderId = providerId, - ProviderName = GetProviderName(providerId), - ThrottleLevel = state.ThrottleLevel, - InitiatedAt = DateTime.UtcNow, - CorrelationId = Guid.NewGuid().ToString() - }); - } - } - - private async Task CheckFailoverRecoveryAsync(int originalProviderId, FailoverState failoverState) - { - // Check if original provider has recovered - if (_providerStates.TryGetValue(originalProviderId, out var state) && - state.IsHealthy && - !state.IsQuarantined) - { - var originalProviderName = GetProviderName(originalProviderId); - _logger.LogInformation( - "Original provider {ProviderId} ({ProviderName}) has recovered, reversing failover", - originalProviderId, originalProviderName); - - // Gradually shift traffic back - failoverState.Status = FailoverStatus.Recovering; - - // Update routing to gradually restore traffic - await RestoreOriginalProviderAsync(originalProviderId, failoverState.FailoverProviderId); - } - } - - private async Task RestoreOriginalProviderAsync(int originalProviderId, int failoverProviderId) - { - if (_publishEndpoint != null) - { - await _publishEndpoint.Publish(new ProviderFailoverReverted - { - OriginalProviderId = originalProviderId, - OriginalProviderName = GetProviderName(originalProviderId), - FailoverProviderId = failoverProviderId, - FailoverProviderName = GetProviderName(failoverProviderId), - RevertedAt = DateTime.UtcNow, - CorrelationId = Guid.NewGuid().ToString() - }); - } - - // Remove failover state after successful restoration - _failoverStates.TryRemove(originalProviderId, out _); - } - - private async Task PerformSelfHealingAsync() - { - // Check for common issues and attempt to fix them - - // 1. Clear stale cache entries - await ClearStaleCacheEntriesAsync(); - - // 2. Reset stuck circuit breakers - await ResetStuckCircuitBreakersAsync(); - - // 3. Rebalance provider load - await RebalanceProviderLoadAsync(); - - // 4. Clean up old metrics data - await CleanupOldMetricsAsync(); - } - - private async Task ClearStaleCacheEntriesAsync() - { - // This would clear cache entries that might be causing issues - _logger.LogDebug("Clearing stale cache entries"); - await Task.CompletedTask; - } - - private async Task ResetStuckCircuitBreakersAsync() - { - // Reset circuit breakers that have been open too long - var stuckProviders = _providerStates - .Where(p => p.Value.IsQuarantined && - p.Value.QuarantinedAt.HasValue && - DateTime.UtcNow - p.Value.QuarantinedAt.Value > TimeSpan.FromHours(1)) - .ToList(); - - foreach (var (providerId, state) in stuckProviders) - { - var providerName = GetProviderName(providerId); - _logger.LogInformation("Resetting stuck circuit breaker for provider {ProviderId} ({ProviderName})", providerId, providerName); - await CheckProviderRecoveryAsync(providerId, state); - } - } - - private async Task RebalanceProviderLoadAsync() - { - // Ensure load is properly distributed among healthy providers - var healthyProviders = _providerStates - .Where(p => p.Value.IsHealthy && !p.Value.IsQuarantined) - .ToList(); - - if (healthyProviders.Count() > 1) - { - // Calculate optimal weights based on health scores - var totalScore = healthyProviders.Sum(p => p.Value.HealthScore); - - foreach (var (providerId, state) in healthyProviders) - { - var weight = state.HealthScore / totalScore; - await UpdateProviderWeightAsync(providerId, weight); - } - } - - await Task.CompletedTask; - } - - private async Task CleanupOldMetricsAsync() - { - // Trigger metrics cleanup - // TODO: Implement cleanup when method is available - await Task.CompletedTask; - } - - private async Task UpdateProviderWeightAsync(int providerId, double weight) - { - // This would update the provider's weight in the load balancing configuration - var providerName = GetProviderName(providerId); - _logger.LogDebug("Updated provider {ProviderId} ({ProviderName}) weight to {Weight:F2}", providerId, providerName, weight); - await Task.CompletedTask; - } - - private async Task CheckGlobalHealthAsync(ImageGenerationMetricsSnapshot metrics) - { - // Check for system-wide issues - - // High error rate across all providers - if (metrics.SuccessRate < 90) - { - _logger.LogWarning("System-wide high error rate detected: {Rate:F1}%", 100 - metrics.SuccessRate); - - // Implement global mitigation strategies - await ImplementGlobalMitigationAsync("high_error_rate"); - } - - // Queue backup - if (metrics.QueueMetrics.TotalDepth > _options.QueueDepthThreshold) - { - _logger.LogWarning("Queue depth critical: {Depth} items", metrics.QueueMetrics.TotalDepth); - - // Implement queue management strategies - await ImplementGlobalMitigationAsync("queue_backup"); - } - } - - private async Task ImplementGlobalMitigationAsync(string issueType) - { - switch (issueType) - { - case "high_error_rate": - // Enable more aggressive retries - // Increase timeouts - // Enable fallback models - break; - - case "queue_backup": - // Increase concurrency limits - // Enable request shedding for low-priority requests - // Scale out workers if possible - break; - } - - await Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Types.cs b/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Types.cs deleted file mode 100644 index 50179b164..000000000 --- a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.Types.cs +++ /dev/null @@ -1,107 +0,0 @@ -namespace ConduitLLM.Core.Services -{ - /// - /// Provides self-healing and automatic failover capabilities for image generation - Type definitions - /// - public partial class ImageGenerationResilienceService - { - private class ProviderHealthState - { - public int ProviderId { get; set; } - public bool IsHealthy { get; set; } = true; - public double HealthScore { get; set; } = 1.0; - public int ConsecutiveFailures { get; set; } - public DateTime LastChecked { get; set; } - public bool IsQuarantined { get; set; } - public DateTime? QuarantinedAt { get; set; } - public string? QuarantineReason { get; set; } - public bool IsThrottled { get; set; } - public double ThrottleLevel { get; set; } = 1.0; - public DateTime? RecoveryStarted { get; set; } - public bool IsPermanentlyFailed { get; set; } - } - - private class FailoverState - { - public int FailedProviderId { get; set; } - public int FailoverProviderId { get; set; } - public DateTime InitiatedAt { get; set; } - public FailoverStatus Status { get; set; } - public string Reason { get; set; } = string.Empty; - } - - private enum FailoverStatus - { - Initiated, - Active, - Recovering, - Completed, - NoAlternative - } - - private class RecoveryAttempt - { - public int ProviderId { get; set; } - public int AttemptCount { get; set; } - public DateTime LastAttempt { get; set; } = DateTime.UtcNow; - } - } - - /// - /// Configuration options for image generation resilience. - /// - public class ImageGenerationResilienceOptions - { - public bool Enabled { get; set; } = true; - public int HealthCheckIntervalMinutes { get; set; } = 2; - public int RecoveryCheckIntervalMinutes { get; set; } = 5; - public int FailureThreshold { get; set; } = 3; - public double SlowResponseThresholdMs { get; set; } = 30000; - public double RecoveryHealthScoreThreshold { get; set; } = 0.8; - public TimeSpan MinimumQuarantineTime { get; set; } = TimeSpan.FromMinutes(10); - public TimeSpan MaximumQuarantineTime { get; set; } = TimeSpan.FromHours(24); - public int QueueDepthThreshold { get; set; } = 100; - } - - #region Events - - public class ProviderQuarantined - { - public int ProviderId { get; set; } - public string ProviderName { get; set; } = string.Empty; - public string Reason { get; set; } = string.Empty; - public DateTime QuarantinedAt { get; set; } - public string CorrelationId { get; set; } = string.Empty; - } - - public class ProviderFailoverInitiated - { - public int FailedProviderId { get; set; } - public string FailedProviderName { get; set; } = string.Empty; - public int FailoverProviderId { get; set; } - public string FailoverProviderName { get; set; } = string.Empty; - public DateTime InitiatedAt { get; set; } - public string CorrelationId { get; set; } = string.Empty; - } - - public class ProviderRecoveryInitiated - { - public int ProviderId { get; set; } - public string ProviderName { get; set; } = string.Empty; - public double ThrottleLevel { get; set; } - public DateTime InitiatedAt { get; set; } - public string CorrelationId { get; set; } = string.Empty; - } - - public class ProviderFailoverReverted - { - public int OriginalProviderId { get; set; } - public string OriginalProviderName { get; set; } = string.Empty; - public int FailoverProviderId { get; set; } - public string FailoverProviderName { get; set; } = string.Empty; - public DateTime RevertedAt { get; set; } - public string CorrelationId { get; set; } = string.Empty; - } - - #endregion -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.cs b/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.cs deleted file mode 100644 index b037a7e40..000000000 --- a/Shared/ConduitLLM.Core/Services/ImageGenerationResilienceService.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System.Collections.Concurrent; -using ConduitLLM.Core.Interfaces; -using MassTransit; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -using ConduitLLM.Configuration.Interfaces; -namespace ConduitLLM.Core.Services -{ - /// - /// Provides self-healing and automatic failover capabilities for image generation. - /// - public partial class ImageGenerationResilienceService : BackgroundService - { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly IImageGenerationMetricsCollector _metricsCollector; - private readonly IImageGenerationAlertingService _alertingService; - private readonly ImageGenerationResilienceOptions _options; - private readonly IPublishEndpoint? _publishEndpoint; - - private readonly ConcurrentDictionary _providerStates = new(); - private readonly ConcurrentDictionary _failoverStates = new(); - private readonly ConcurrentDictionary _recoveryAttempts = new(); - - // Cache for providers - private readonly ConcurrentDictionary _providerCache = new(); - - private Timer? _healthCheckTimer; - private Timer? _recoveryTimer; - - public ImageGenerationResilienceService( - IServiceProvider serviceProvider, - ILogger logger, - IImageGenerationMetricsCollector metricsCollector, - IImageGenerationAlertingService alertingService, - IOptions options, - IPublishEndpoint? publishEndpoint = null) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _metricsCollector = metricsCollector ?? throw new ArgumentNullException(nameof(metricsCollector)); - _alertingService = alertingService ?? throw new ArgumentNullException(nameof(alertingService)); - _options = options?.Value ?? new ImageGenerationResilienceOptions(); - _publishEndpoint = publishEndpoint; - } - - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_options.Enabled) - { - _logger.LogInformation("Image generation resilience service is disabled"); - return Task.CompletedTask; - } - - _logger.LogInformation( - "Image generation resilience service started - Health check: {HealthInterval}min, Recovery: {RecoveryInterval}min", - _options.HealthCheckIntervalMinutes, _options.RecoveryCheckIntervalMinutes); - - // Initialize provider states - _ = RefreshProviderCacheAsync(stoppingToken); - - // Start health monitoring timer - _healthCheckTimer = new Timer( - async _ => await PerformHealthChecksAsync(stoppingToken), - null, - TimeSpan.FromSeconds(30), // Initial delay - TimeSpan.FromMinutes(_options.HealthCheckIntervalMinutes)); - - // Start recovery timer - _recoveryTimer = new Timer( - async _ => await PerformRecoveryChecksAsync(stoppingToken), - null, - TimeSpan.FromMinutes(1), // Initial delay - TimeSpan.FromMinutes(_options.RecoveryCheckIntervalMinutes)); - - return Task.CompletedTask; - } - - private async Task PerformHealthChecksAsync(CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - return; - - try - { - // Get current metrics - var metrics = await _metricsCollector.GetMetricsSnapshotAsync(cancellationToken); - - // Check each provider - foreach (var (providerName, status) in metrics.ProviderStatuses) - { - // Try to find provider ID from name - var providerId = GetProviderIdFromName(providerName); - if (providerId.HasValue) - { - await CheckProviderHealthAsync(providerId.Value, status, metrics); - } - else - { - _logger.LogWarning("Could not find provider ID for provider name: {ProviderName}", providerName); - } - } - - // Check for global issues - await CheckGlobalHealthAsync(metrics); - - // Trigger alert evaluation - await _alertingService.EvaluateMetricsAsync(metrics, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error performing resilience health checks"); - } - } - - private async Task CheckProviderHealthAsync( - int providerId, - ProviderStatus status, - ImageGenerationMetricsSnapshot metrics) - { - var state = _providerStates.GetOrAdd(providerId, new ProviderHealthState - { - ProviderId = providerId - }); - - // Update health state - state.IsHealthy = status.IsHealthy; - state.HealthScore = status.HealthScore; - state.ConsecutiveFailures = status.ConsecutiveFailures; - state.LastChecked = DateTime.UtcNow; - - // Check if provider needs intervention - if (!status.IsHealthy || status.ConsecutiveFailures >= _options.FailureThreshold) - { - await HandleUnhealthyProviderAsync(providerId, state, status); - } - else if (state.IsQuarantined && status.HealthScore > _options.RecoveryHealthScoreThreshold) - { - // Provider appears to be recovering - await AttemptProviderRecoveryAsync(providerId, state); - } - - // Check for performance degradation - if (status.AverageResponseTimeMs > _options.SlowResponseThresholdMs) - { - await HandleSlowProviderAsync(providerId, status); - } - } - - - - private int? GetProviderIdFromName(string providerName) - { - // Try to find provider by name in cache - var provider = _providerCache.Values.FirstOrDefault(p => - p.ProviderName == providerName || p.ProviderType.ToString() == providerName); - return provider?.Id; - } - - private string GetProviderName(int providerId) - { - if (_providerCache.TryGetValue(providerId, out var provider)) - { - return provider.ProviderName ?? provider.ProviderType.ToString(); - } - return $"Provider_{providerId}"; - } - - - private async Task RefreshProviderCacheAsync(CancellationToken cancellationToken) - { - using var scope = _serviceProvider.CreateScope(); - var providerService = scope.ServiceProvider.GetService(); - - if (providerService == null) - { - _logger.LogWarning("IProviderService not available, cannot refresh provider cache"); - return; - } - - try - { - var providers = await providerService.GetAllProvidersAsync(); - - // Update the provider cache - _providerCache.Clear(); - foreach (var provider in providers) - { - _providerCache[provider.Id] = provider; - - // Initialize health state for enabled providers - if (provider.IsEnabled && !_providerStates.ContainsKey(provider.Id)) - { - _providerStates[provider.Id] = new ProviderHealthState - { - ProviderId = provider.Id, - IsHealthy = true, - HealthScore = 1.0 - }; - } - } - - _logger.LogInformation("Refreshed provider cache. Found {Count} providers", _providerCache.Count()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh provider cache"); - } - } - - private bool IsPrimaryProvider(int providerId) - { - // Determine if this is a primary provider that requires immediate failover - // In the new model, this should be based on provider configuration, not hardcoded - // Determine if this is a primary provider that requires immediate failover - if (_providerCache.TryGetValue(providerId, out var provider)) - { - // TODO: Add IsPrimary flag to Provider entity - // For now, check the provider name - var name = provider.ProviderName ?? string.Empty; - return name.Contains("Primary", StringComparison.OrdinalIgnoreCase) || - name.Contains("Production", StringComparison.OrdinalIgnoreCase); - } - return false; - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Image generation resilience service is stopping"); - - _healthCheckTimer?.Change(Timeout.Infinite, 0); - _healthCheckTimer?.Dispose(); - - _recoveryTimer?.Change(Timeout.Infinite, 0); - _recoveryTimer?.Dispose(); - - await base.StopAsync(cancellationToken); - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/ImageTokenCalculator.cs b/Shared/ConduitLLM.Core/Services/ImageTokenCalculator.cs index 549933983..f3d53ed12 100644 --- a/Shared/ConduitLLM.Core/Services/ImageTokenCalculator.cs +++ b/Shared/ConduitLLM.Core/Services/ImageTokenCalculator.cs @@ -131,9 +131,9 @@ public async Task CalculateImageTokensAsync(ImageUrl imageUrl) try { // First try to get dimensions from headers (if server supports it) - var headRequest = new HttpRequestMessage(HttpMethod.Head, url); - var headResponse = await _httpClient.SendAsync(headRequest); - + using var headRequest = new HttpRequestMessage(HttpMethod.Head, url); + using var headResponse = await _httpClient.SendAsync(headRequest); + if (headResponse.Headers.TryGetValues("X-Image-Width", out var widthValues) && headResponse.Headers.TryGetValues("X-Image-Height", out var heightValues)) { @@ -146,7 +146,7 @@ public async Task CalculateImageTokensAsync(ImageUrl imageUrl) // If headers don't contain dimensions, download the image // We only need the first few bytes to determine dimensions for most formats - var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); using var stream = await response.Content.ReadAsStreamAsync(); // Read enough bytes to get image dimensions (usually in the header) @@ -237,8 +237,11 @@ public async Task CalculateImageTokensAsync(ImageUrl imageUrl) offset += segmentLength; } } - catch { } - + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse JPEG dimensions from image bytes"); + } + return (0, 0); } @@ -256,8 +259,11 @@ public async Task CalculateImageTokensAsync(ImageUrl imageUrl) int height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; return (width, height); } - catch { } - + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse PNG dimensions from image bytes"); + } + return (0, 0); } @@ -274,8 +280,11 @@ public async Task CalculateImageTokensAsync(ImageUrl imageUrl) int height = bytes[8] | (bytes[9] << 8); return (width, height); } - catch { } - + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse GIF dimensions from image bytes"); + } + return (0, 0); } @@ -309,8 +318,11 @@ public async Task CalculateImageTokensAsync(ImageUrl imageUrl) } } } - catch { } - + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse WebP dimensions from image bytes"); + } + return (0, 0); } diff --git a/Shared/ConduitLLM.Core/Services/InMemoryMediaStorageService.cs b/Shared/ConduitLLM.Core/Services/InMemoryMediaStorageService.cs index 86b2fd966..86daf3465 100644 --- a/Shared/ConduitLLM.Core/Services/InMemoryMediaStorageService.cs +++ b/Shared/ConduitLLM.Core/Services/InMemoryMediaStorageService.cs @@ -349,7 +349,7 @@ public async Task UploadPartAsync(string sessionId, int partNu } /// - public Task CompleteMultipartUploadAsync(string sessionId, List parts) + public async Task CompleteMultipartUploadAsync(string sessionId, List parts) { if (!_multipartSessions.TryRemove(sessionId, out var session)) { @@ -364,7 +364,7 @@ public Task CompleteMultipartUploadAsync(string sessionId, L // Combine all parts var sortedParts = parts.OrderBy(p => p.PartNumber).ToList(); using var finalStream = new MemoryStream(); - + foreach (var part in sortedParts) { if (partData.TryGetValue(part.PartNumber, out var data)) @@ -394,16 +394,16 @@ public Task CompleteMultipartUploadAsync(string sessionId, L _logger.LogInformation("Completed in-memory multipart upload for key {StorageKey}", session.StorageKey); - var url = GenerateUrlAsync(session.StorageKey).Result; + var url = await GenerateUrlAsync(session.StorageKey); - return Task.FromResult(new MediaStorageResult + return new MediaStorageResult { StorageKey = session.StorageKey, Url = url, SizeBytes = finalData.Length, ContentHash = contentHash, CreatedAt = DateTime.UtcNow - }); + }; } /// diff --git a/Shared/ConduitLLM.Core/Services/MediaLifecycleService.cs b/Shared/ConduitLLM.Core/Services/MediaLifecycleService.cs index 02b0dbd2c..bc239ad71 100644 --- a/Shared/ConduitLLM.Core/Services/MediaLifecycleService.cs +++ b/Shared/ConduitLLM.Core/Services/MediaLifecycleService.cs @@ -1,4 +1,5 @@ using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Interfaces; @@ -75,13 +76,13 @@ public async Task TrackMediaAsync( AccessCount = 0 }; - var created = await _mediaRepository.CreateAsync(mediaRecord); - + await _mediaRepository.CreateAsync(mediaRecord); + _logger.LogInformation( "Tracked media {StorageKey} of type {MediaType} for virtual key {VirtualKeyId}", storageKey, mediaType, virtualKeyId); - - return created; + + return mediaRecord; } /// @@ -354,7 +355,8 @@ public async Task GetOverallStorageStatsAsync(int? vir } // Get virtual keys for this group - var virtualKeys = await _virtualKeyRepository.GetByVirtualKeyGroupIdAsync(virtualKeyGroupId.Value); + var virtualKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _virtualKeyRepository.GetByVirtualKeyGroupIdPaginatedAsync, virtualKeyGroupId.Value); var virtualKeyIds = virtualKeys.Select(vk => vk.Id).ToList(); // Get media only for these virtual keys diff --git a/Shared/ConduitLLM.Core/Services/ModelCapabilityDetector.cs b/Shared/ConduitLLM.Core/Services/ModelCapabilityDetector.cs deleted file mode 100644 index 7910d95a5..000000000 --- a/Shared/ConduitLLM.Core/Services/ModelCapabilityDetector.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Text.Json; - -using ConduitLLM.Core.Interfaces; -using ConduitLLM.Core.Models; - -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Core.Services -{ - /// - /// Provides detection and validation of model capabilities, particularly for - /// specialized features like vision/multimodal support. - /// Now uses IModelCapabilityService for database-driven capability detection. - /// - public class ModelCapabilityDetector : IModelCapabilityDetector - { - private readonly ILogger _logger; - private readonly IModelCapabilityService? _capabilityService; - private readonly ILLMClientFactory _clientFactory; - - // Removed hardcoded patterns - now using IModelCapabilityService for all capability detection - - /// - /// Initializes a new instance of the ModelCapabilityDetector. - /// - /// Logger for diagnostics information - /// Service for retrieving model capabilities from configuration - /// Factory for creating LLM clients - public ModelCapabilityDetector( - ILogger logger, - IModelCapabilityService? capabilityService, - ILLMClientFactory clientFactory) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _capabilityService = capabilityService; - _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); - - if (capabilityService == null) - { - _logger.LogError("ModelCapabilityService not available - model capability detection will not function properly"); - } - } - - /// - /// Determines if a model has vision (image processing) capabilities. - /// - /// The name of the model to check - /// True if the model supports vision input, false otherwise - public bool HasVisionCapability(string modelName) - { - if (string.IsNullOrEmpty(modelName)) - return false; - - // Use capability service if available - if (_capabilityService != null) - { - try - { - var hasVision = _capabilityService.SupportsVisionAsync(modelName).GetAwaiter().GetResult(); - return hasVision; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking vision capability for model {Model}", modelName); - return false; - } - } - - _logger.LogWarning("Cannot check vision capability for model {Model} - ModelCapabilityService not available", modelName); - return false; - } - - /// - /// Determines if a chat completion request contains image content that - /// requires a vision-capable model. - /// - /// The chat completion request to check - /// True if the request contains image content, false otherwise - public bool ContainsImageContent(ChatCompletionRequest request) - { - if (request?.Messages == null || request.Messages.Count() == 0) - return false; - - foreach (var message in request.Messages) - { - if (message.Content == null) - continue; - - // Check for content that is not a string (likely multimodal) - if (message.Content is not string) - { - // Handle JsonElement case from deserialization - if (message.Content is JsonElement jsonElement) - { - if (jsonElement.ValueKind == JsonValueKind.Array) - { - // Look for image_url parts in the content array - foreach (var part in jsonElement.EnumerateArray()) - { - if (part.TryGetProperty("type", out var typeProperty) && - typeProperty.GetString() == "image_url") - { - return true; - } - } - } - } - // Handle collection case from direct API usage - else if (message.Content is IEnumerable contentParts) - { - foreach (var part in contentParts) - { - if (part is ImageUrlContentPart) - return true; - - // Try to extract type property dynamically - var type = part.GetType().GetProperty("Type")?.GetValue(part)?.ToString(); - if (type == "image_url") - return true; - } - } - } - } - - return false; - } - - /// - /// Gets a list of all available models that support vision capabilities. - /// - /// A collection of model names that support vision - public IEnumerable GetVisionCapableModels() - { - _logger.LogWarning("GetVisionCapableModels called - this method needs to be made async to properly query ModelCapabilityService"); - // This method should be made async to properly query the capability service - // For now, return empty list when capability service is not available - return Enumerable.Empty(); - } - - /// - /// Validates that a request can be processed by the specified model. - /// - /// The chat completion request to validate - /// The name of the model to check - /// Error message if validation fails - /// True if the request is valid for the model, false otherwise - public bool ValidateRequestForModel(ChatCompletionRequest request, string modelName, out string errorMessage) - { - errorMessage = string.Empty; - - if (request == null) - { - errorMessage = "Request cannot be null"; - return false; - } - - if (string.IsNullOrEmpty(modelName)) - { - errorMessage = "Model name cannot be null or empty"; - return false; - } - - // Check if request contains images but model doesn't support vision - if (ContainsImageContent(request) && !HasVisionCapability(modelName)) - { - errorMessage = $"Model '{modelName}' does not support vision/image inputs"; - return false; - } - - return true; - } - } -} diff --git a/Shared/ConduitLLM.Core/Services/PerformanceMetricsService.cs b/Shared/ConduitLLM.Core/Services/PerformanceMetricsService.cs index f98246536..ca910abe1 100644 --- a/Shared/ConduitLLM.Core/Services/PerformanceMetricsService.cs +++ b/Shared/ConduitLLM.Core/Services/PerformanceMetricsService.cs @@ -139,7 +139,7 @@ public PerformanceMetrics GetMetrics(Usage? usage = null) }; // Calculate average inter-token latency - if (_interTokenLatencies.Count() > 0) + if (_interTokenLatencies.Any()) { metrics.AvgInterTokenLatencyMs = _interTokenLatencies.Average(); } diff --git a/Shared/ConduitLLM.Core/Services/PromptCacheInjectionService.cs b/Shared/ConduitLLM.Core/Services/PromptCacheInjectionService.cs new file mode 100644 index 000000000..b82a63a5b --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/PromptCacheInjectionService.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using ConduitLLM.Core.Models; + +namespace ConduitLLM.Core.Services; + +/// +/// Service that injects cache_control directives into chat completion request messages +/// based on a . +/// +public static class PromptCacheInjectionService +{ + /// + /// Maximum number of cache_control breakpoints that Anthropic allows per request. + /// + private const int MaxCachedBlocks = 4; + + /// + /// Injects cache_control directives into the request messages in-place. + /// + /// The chat completion request to modify. + /// The caching configuration. + public static void InjectCacheControl(ChatCompletionRequest request, PromptCachingConfig config) + { + if (!config.AutoInjectEnabled || config.InjectionPoints.Count == 0) + return; + + var injectedCount = 0; + + foreach (var point in config.InjectionPoints) + { + if (injectedCount >= MaxCachedBlocks) + break; + + var targetMessages = FindTargetMessages(request.Messages, point); + + foreach (var message in targetMessages) + { + if (injectedCount >= MaxCachedBlocks) + break; + + InjectCacheControlOnMessage(message); + injectedCount++; + } + } + } + + /// + /// Finds messages matching the injection point criteria. + /// + private static List FindTargetMessages(List messages, CacheInjectionPoint point) + { + // Filter by role if specified + var candidates = point.Role != null + ? messages.Where(m => string.Equals(m.Role, point.Role, StringComparison.OrdinalIgnoreCase)).ToList() + : messages.ToList(); + + if (candidates.Count == 0) + return candidates; + + // Apply index selection if specified + if (point.Index.HasValue) + { + var idx = point.Index.Value; + + // Resolve negative indices + if (idx < 0) + idx = candidates.Count + idx; + + if (idx >= 0 && idx < candidates.Count) + return new List { candidates[idx] }; + + return new List(); + } + + return candidates; + } + + /// + /// Adds cache_control to the last content block of a message. + /// If content is a plain string, converts it to a content array first. + /// + private static void InjectCacheControlOnMessage(Message message) + { + var cacheControl = new Dictionary { ["type"] = "ephemeral" }; + + if (message.Content == null) + return; + + if (message.Content is string textContent) + { + // Convert string to content array with cache_control + message.Content = new List + { + new Dictionary + { + ["type"] = "text", + ["text"] = textContent, + ["cache_control"] = cacheControl + } + }; + return; + } + + if (message.Content is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Array) + { + // Convert to mutable list, add cache_control to last element + var elements = new List>(); + foreach (var element in jsonElement.EnumerateArray()) + { + var dict = new Dictionary(); + foreach (var prop in element.EnumerateObject()) + { + dict[prop.Name] = ConvertJsonElement(prop.Value); + } + elements.Add(dict); + } + + if (elements.Count > 0) + { + elements[^1]["cache_control"] = cacheControl; + } + + message.Content = elements.Cast().ToList(); + return; + } + + // If content is already a List, add cache_control to the last element + if (message.Content is IList contentList && contentList.Count > 0) + { + var lastItem = contentList[^1]; + if (lastItem is Dictionary dict) + { + dict["cache_control"] = cacheControl; + } + } + } + + private static object? ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt32(out var i) => i, + JsonValueKind.Number when element.TryGetInt64(out var l) => l, + JsonValueKind.Number => element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToList(), + JsonValueKind.Object => element.EnumerateObject() + .ToDictionary(p => p.Name, p => ConvertJsonElement(p.Value)), + _ => element.ToString() + }; + } +} diff --git a/Shared/ConduitLLM.Core/Services/ProviderErrorTrackingService.cs b/Shared/ConduitLLM.Core/Services/ProviderErrorTrackingService.cs index 98a4164a5..c344f330c 100644 --- a/Shared/ConduitLLM.Core/Services/ProviderErrorTrackingService.cs +++ b/Shared/ConduitLLM.Core/Services/ProviderErrorTrackingService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using ConduitLLM.Configuration.Events; +using ConduitLLM.Configuration.Extensions; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; @@ -170,7 +171,8 @@ await publishEndpoint.Publish(new ProviderKeyDisabledEvent await _errorStore.AddDisabledKeyToProviderAsync(key.ProviderId, keyId); // Check if all keys are now disabled - if so, disable the provider - var allKeys = await keyRepo.GetByProviderIdAsync(key.ProviderId); + var allKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + keyRepo.GetByProviderIdPaginatedAsync, key.ProviderId); if (allKeys.All(k => !k.IsEnabled)) { var provider = await providerRepo.GetByIdAsync(key.ProviderId); @@ -209,13 +211,20 @@ await publishEndpoint.Publish(new ProviderKeyDisabledEvent } public async Task> GetRecentErrorsAsync( - int? providerId = null, + int? providerId = null, int? keyId = null, int limit = 100) { - var entries = await _errorStore.GetRecentErrorsAsync(limit); + bool hasFilter = providerId.HasValue || keyId.HasValue; + // When filtering, fetch more entries to compensate for post-filter reduction + int fetchLimit = hasFilter ? limit * 5 : limit; + // Cap to prevent excessive Redis reads + if (fetchLimit > 5000) + fetchLimit = 5000; + + var entries = await _errorStore.GetRecentErrorsAsync(fetchLimit); var errors = new List(); - + foreach (var entry in entries) { // Apply filters @@ -223,7 +232,7 @@ public async Task> GetRecentErrorsAsync( continue; if (keyId.HasValue && entry.KeyId != keyId.Value) continue; - + errors.Add(new ProviderErrorInfo { KeyCredentialId = entry.KeyId, @@ -232,8 +241,11 @@ public async Task> GetRecentErrorsAsync( ErrorMessage = entry.Message, OccurredAt = entry.Timestamp }); + + if (errors.Count >= limit) + break; } - + return errors; } @@ -241,8 +253,9 @@ public async Task> GetErrorCountsByKeyAsync(int providerId, { using var scope = _scopeFactory.CreateScope(); var keyRepo = scope.ServiceProvider.GetRequiredService(); - - var keys = await keyRepo.GetByProviderIdAsync(providerId); + + var keys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + keyRepo.GetByProviderIdPaginatedAsync, providerId); var keyIds = keys.Select(k => k.Id).ToList(); var errorCounts = await _errorStore.GetErrorCountsByKeysAsync(providerId, keyIds, window); @@ -250,9 +263,9 @@ public async Task> GetErrorCountsByKeyAsync(int providerId, return errorCounts.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Count); } - public async Task ClearErrorsForKeyAsync(int keyId) + public async Task ClearErrorsForKeyAsync(int keyId, int? providerId = null) { - await _errorStore.ClearErrorsForKeyAsync(keyId); + await _errorStore.ClearErrorsForKeyAsync(keyId, providerId); } public async Task GetKeyErrorDetailsAsync(int keyId) @@ -331,14 +344,18 @@ public async Task GetErrorStatisticsAsync(TimeSpan window) TotalErrors = statsData.TotalErrors, FatalErrors = statsData.FatalErrors, Warnings = statsData.Warnings, - ErrorsByType = statsData.ErrorsByType + ErrorsByType = statsData.ErrorsByType, + ErrorsByProvider = statsData.ErrorsByProvider.ToDictionary( + kvp => kvp.Key.ToString(), + kvp => kvp.Value) }; // Count disabled keys using (var scope = _scopeFactory.CreateScope()) { var keyRepo = scope.ServiceProvider.GetRequiredService(); - var keys = await keyRepo.GetAllAsync(); + var keys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + keyRepo.GetPaginatedAsync); stats.DisabledKeys = keys.Count(k => !k.IsEnabled); } diff --git a/Shared/ConduitLLM.Core/Services/ProviderMetadataRegistry.cs b/Shared/ConduitLLM.Core/Services/ProviderMetadataRegistry.cs index 9153de825..3da09ecfb 100644 --- a/Shared/ConduitLLM.Core/Services/ProviderMetadataRegistry.cs +++ b/Shared/ConduitLLM.Core/Services/ProviderMetadataRegistry.cs @@ -180,7 +180,7 @@ private void DiscoverAndRegisterProviders() .Where(pt => !_providers.ContainsKey(pt)) .ToList(); - if (missingProviders.Count() > 0) + if (missingProviders.Any()) { var missing = string.Join(", ", missingProviders); var error = $"Missing provider implementations for: {missing}"; @@ -210,7 +210,7 @@ private void AddCapabilityGroup(Dictionary> groups, .OrderBy(n => n) .ToList(); - if (providers.Count() > 0) + if (providers.Any()) { groups[capability] = providers; } diff --git a/Shared/ConduitLLM.Core/Services/RedisCacheServiceBase.cs b/Shared/ConduitLLM.Core/Services/RedisCacheServiceBase.cs new file mode 100644 index 000000000..41e9e2a6d --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/RedisCacheServiceBase.cs @@ -0,0 +1,196 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; + +namespace ConduitLLM.Core.Services; + +/// +/// Base class for Redis-backed cache services providing common infrastructure: +/// get-with-fallback, stats tracking, key scanning, and cache entry management. +/// +/// +/// Subclasses that use buffered statistics should override , +/// , and to use +/// local counters with periodic flush instead of direct Redis increments. +/// +public abstract class RedisCacheServiceBase +{ + protected readonly IDatabase Database; + protected readonly ILogger Logger; + protected readonly JsonSerializerOptions JsonOptions; + protected readonly TimeSpan DefaultExpiry; + + protected RedisCacheServiceBase( + IConnectionMultiplexer redis, + ILogger logger, + TimeSpan defaultExpiry, + JsonSerializerOptions? jsonOptions = null) + { + Database = redis.GetDatabase(); + Logger = logger; + DefaultExpiry = defaultExpiry; + JsonOptions = jsonOptions ?? new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + } + + /// + /// Core get-with-fallback pattern: check cache โ†’ deserialize โ†’ track hit/miss โ†’ fallback to DB โ†’ cache result. + /// + /// The cached entity type + /// Redis key to look up + /// Stats service name for tracking + /// Async function to load from database on cache miss + /// If true, caches the DB result (default true) + /// Custom expiry, or null to use + /// Label for debug log messages (e.g., "Global setting: AuthKey") + /// The cached or freshly-loaded entity, or null if not found + protected async Task GetOrFallbackAsync( + string cacheKey, + string serviceName, + Func> dbFallback, + bool cacheResult = true, + TimeSpan? expiry = null, + string? debugLabel = null) where T : class + { + try + { + var cachedValue = await Database.StringGetAsync(cacheKey); + + if (cachedValue.HasValue) + { + var jsonString = (string?)cachedValue; + if (jsonString is not null) + { + var result = JsonSerializer.Deserialize(jsonString, JsonOptions); + if (result != null) + { + Logger.LogDebug("Cache hit: {Label}", debugLabel ?? cacheKey); + await TrackHitAsync(serviceName); + return result; + } + } + } + + // Cache miss โ€” load from database + Logger.LogDebug("Cache miss, querying database: {Label}", debugLabel ?? cacheKey); + await TrackMissAsync(serviceName); + + var dbResult = await dbFallback(); + + if (dbResult != null && cacheResult) + { + await SetCacheEntryAsync(cacheKey, dbResult, expiry); + } + + return dbResult; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error accessing cache, falling back to database: {Label}", debugLabel ?? cacheKey); + await TrackMissAsync(serviceName); + return await dbFallback(); + } + } + + /// + /// Serialize and store a value in Redis. + /// + protected async Task SetCacheEntryAsync(string cacheKey, T value, TimeSpan? expiry = null) + { + var json = JsonSerializer.Serialize(value, JsonOptions); + await Database.StringSetAsync(cacheKey, json, expiry ?? DefaultExpiry); + } + + /// + /// Scan for keys matching a pattern and delete them all. + /// + protected async Task ClearAllByPatternAsync(string pattern) + { + try + { + var server = Database.Multiplexer.GetServer(Database.Multiplexer.GetEndPoints()[0]); + var keys = server.Keys(pattern: pattern); + + foreach (var key in keys) + { + await Database.KeyDeleteAsync(key); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error clearing cache entries by pattern: {Pattern}", pattern); + } + } + + /// + /// Count matching keys by scanning the Redis keyspace. + /// + protected long CountEntries(string pattern) + { + var server = Database.Multiplexer.GetServer(Database.Multiplexer.GetEndPoints()[0]); + var count = 0L; + foreach (var _ in server.Keys(pattern: pattern)) + { + count++; + } + return count; + } + + /// + /// Read base stats (hits, misses, invalidations, reset time) from Redis for a given service. + /// + protected async Task<(long hits, long misses, long invalidations, DateTime resetTime)> GetBaseStatsAsync(string serviceName) + { + var hits = await Database.StringGetAsync(CacheKeys.Stats.Hits(serviceName)); + var misses = await Database.StringGetAsync(CacheKeys.Stats.Misses(serviceName)); + var invalidations = await Database.StringGetAsync(CacheKeys.Stats.Invalidations(serviceName)); + var resetTime = await Database.StringGetAsync(CacheKeys.Stats.ResetTime(serviceName)); + + return ( + hits.HasValue ? (long)hits : 0, + misses.HasValue ? (long)misses : 0, + invalidations.HasValue ? (long)invalidations : 0, + resetTime.HasValue && DateTime.TryParse(resetTime, out var time) ? time : DateTime.UtcNow + ); + } + + /// + /// Initialize the stats reset time key if it doesn't already exist (fire-and-forget). + /// Call this from the subclass constructor. + /// + protected void InitializeStatsResetTime(string serviceName) + { + _ = Database.StringSetAsync( + CacheKeys.Stats.ResetTime(serviceName), + DateTime.UtcNow.ToString("O"), + when: When.NotExists) + .ContinueWith(t => + { + if (t.IsFaulted) + { + Logger.LogWarning(t.Exception, "Failed to initialize stats reset time"); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + + /// + /// Track a cache hit. Override for buffered stats. + /// Default: direct Redis increment. + /// + protected virtual Task TrackHitAsync(string serviceName) + => Database.StringIncrementAsync(CacheKeys.Stats.Hits(serviceName)); + + /// + /// Track a cache miss. Override for buffered stats. + /// Default: direct Redis increment. + /// + protected virtual Task TrackMissAsync(string serviceName) + => Database.StringIncrementAsync(CacheKeys.Stats.Misses(serviceName)); + + /// + /// Track cache invalidation(s). Override for buffered stats. + /// Default: direct Redis increment. + /// + protected virtual Task TrackInvalidationAsync(string serviceName, long count = 1) + => Database.StringIncrementAsync(CacheKeys.Stats.Invalidations(serviceName), count); +} diff --git a/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsCollector.cs b/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsCollector.cs index b0c209ce8..07a4c2a28 100644 --- a/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsCollector.cs +++ b/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsCollector.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using StackExchange.Redis; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; @@ -10,7 +11,7 @@ namespace ConduitLLM.Core.Services /// /// Redis-based distributed cache statistics collector with atomic operations. /// - public class RedisCacheStatisticsCollector : IDistributedCacheStatisticsCollector + public class RedisCacheStatisticsCollector : IDistributedCacheStatisticsCollector, IAsyncDisposable, IDisposable { private readonly IConnectionMultiplexer _redis; private readonly IDatabase _db; @@ -21,15 +22,6 @@ public class RedisCacheStatisticsCollector : IDistributedCacheStatisticsCollecto private Timer? _heartbeatTimer; private readonly ConcurrentDictionary _alertThresholds; private readonly ConcurrentDictionary _activeAlerts; - - private const string STATS_HASH_KEY = "conduit:cache:stats:{0}:{1}"; // {region}:{instanceId} - private const string GLOBAL_STATS_HASH_KEY = "conduit:cache:stats:{0}:global"; // {region} - private const string RESPONSE_TIMES_KEY = "conduit:cache:response:{0}:{1}:{2}"; // {region}:{operation}:{instanceId} - private const string INSTANCE_SET_KEY = "conduit:cache:instances"; - private const string INSTANCE_HEARTBEAT_KEY = "conduit:cache:heartbeat:{0}"; // {instanceId} - private const string ALERTS_HASH_KEY = "conduit:cache:alerts:{0}"; // {region} - private const string STATS_UPDATE_CHANNEL = "conduit:cache:stats:updates"; - private const string ALERT_CHANNEL = "conduit:cache:alerts"; public string InstanceId => _instanceId; @@ -51,8 +43,8 @@ public RedisCacheStatisticsCollector( // Subscribe to distributed events var subscriber = _redis.GetSubscriber(); - subscriber.Subscribe(RedisChannel.Literal(STATS_UPDATE_CHANNEL), HandleDistributedStatsUpdate); - subscriber.Subscribe(RedisChannel.Literal(ALERT_CHANNEL), HandleDistributedAlert); + subscriber.Subscribe(RedisChannel.Literal(CacheKeys.DistributedStats.UpdateChannel), HandleDistributedStatsUpdate); + subscriber.Subscribe(RedisChannel.Literal(CacheKeys.DistributedStats.AlertChannel), HandleDistributedAlert); // Start heartbeat _heartbeatTimer = new Timer(SendHeartbeat, null, TimeSpan.Zero, _instanceHeartbeatInterval); @@ -64,8 +56,8 @@ public async Task RecordOperationAsync(CacheOperation operation, CancellationTok { var tasks = new List(); var region = operation.Region; - var statsKey = string.Format(STATS_HASH_KEY, region, _instanceId); - var globalKey = string.Format(GLOBAL_STATS_HASH_KEY, region); + var statsKey = CacheKeys.DistributedStats.StatsHash(region.ToString(), _instanceId); + var globalKey = CacheKeys.DistributedStats.GlobalStatsHash(region.ToString()); // Update counters atomically switch (operation.OperationType) @@ -102,7 +94,7 @@ public async Task RecordOperationAsync(CacheOperation operation, CancellationTok if (operation.OperationType == CacheOperationType.Get || operation.OperationType == CacheOperationType.Set) { - var responseKey = string.Format(RESPONSE_TIMES_KEY, region, operation.OperationType, _instanceId); + var responseKey = CacheKeys.DistributedStats.ResponseTimes(region.ToString(), operation.OperationType.ToString(), _instanceId); var score = operation.Duration.TotalMilliseconds; var member = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}:{Guid.NewGuid():N}"; @@ -133,7 +125,7 @@ public async Task RecordOperationAsync(CacheOperation operation, CancellationTok Timestamp = DateTime.UtcNow }); - await _db.PublishAsync(RedisChannel.Literal(STATS_UPDATE_CHANNEL), updateMessage); + await _db.PublishAsync(RedisChannel.Literal(CacheKeys.DistributedStats.UpdateChannel), updateMessage); // Raise local event var stats = await GetStatisticsAsync(region, cancellationToken); @@ -158,7 +150,7 @@ public async Task RecordOperationBatchAsync(IEnumerable operatio public async Task GetStatisticsAsync(CacheRegion region, CancellationToken cancellationToken = default) { - var statsKey = string.Format(STATS_HASH_KEY, region, _instanceId); + var statsKey = CacheKeys.DistributedStats.StatsHash(region.ToString(), _instanceId); var entries = await _db.HashGetAllAsync(statsKey); return ParseStatistics(region, entries); @@ -178,7 +170,7 @@ public async Task> GetAllStatisticsAsyn public async Task GetAggregatedStatisticsAsync(CacheRegion region, CancellationToken cancellationToken = default) { - var globalKey = string.Format(GLOBAL_STATS_HASH_KEY, region); + var globalKey = CacheKeys.DistributedStats.GlobalStatsHash(region.ToString()); var entries = await _db.HashGetAllAsync(globalKey); var stats = ParseStatistics(region, entries); @@ -208,7 +200,7 @@ public async Task> GetPerInstanceStatisticsA foreach (var instance in instances) { - var statsKey = string.Format(STATS_HASH_KEY, region, instance); + var statsKey = CacheKeys.DistributedStats.StatsHash(region.ToString(), instance); var entries = await _db.HashGetAllAsync(statsKey); if (entries.Length > 0) @@ -222,14 +214,14 @@ public async Task> GetPerInstanceStatisticsA public async Task> GetActiveInstancesAsync(CancellationToken cancellationToken = default) { - var members = await _db.SetMembersAsync(INSTANCE_SET_KEY); + var members = await _db.SetMembersAsync(CacheKeys.DistributedStats.InstanceSet); var activeInstances = new List(); var now = DateTimeOffset.UtcNow; foreach (var member in members) { var instanceId = member.ToString(); - var heartbeatKey = string.Format(INSTANCE_HEARTBEAT_KEY, instanceId); + var heartbeatKey = CacheKeys.DistributedStats.Heartbeat(instanceId); var lastHeartbeat = await _db.StringGetAsync(heartbeatKey); if (lastHeartbeat.HasValue && @@ -245,15 +237,15 @@ public async Task> GetActiveInstancesAsync(CancellationToken public async Task RegisterInstanceAsync(CancellationToken cancellationToken = default) { - await _db.SetAddAsync(INSTANCE_SET_KEY, _instanceId); + await _db.SetAddAsync(CacheKeys.DistributedStats.InstanceSet, _instanceId); await SendHeartbeatAsync(); _logger.LogInformation("Registered cache statistics collector instance: {InstanceId}", _instanceId); } public async Task UnregisterInstanceAsync(CancellationToken cancellationToken = default) { - await _db.SetRemoveAsync(INSTANCE_SET_KEY, _instanceId); - var heartbeatKey = string.Format(INSTANCE_HEARTBEAT_KEY, _instanceId); + await _db.SetRemoveAsync(CacheKeys.DistributedStats.InstanceSet, _instanceId); + var heartbeatKey = CacheKeys.DistributedStats.Heartbeat(_instanceId); await _db.KeyDeleteAsync(heartbeatKey); _logger.LogInformation("Unregistered cache statistics collector instance: {InstanceId}", _instanceId); } @@ -295,13 +287,13 @@ public async Task ResetStatisticsAsync(CacheRegion region, CancellationToken can var tasks = new List(); // Reset instance stats - var statsKey = string.Format(STATS_HASH_KEY, region, _instanceId); + var statsKey = CacheKeys.DistributedStats.StatsHash(region.ToString(), _instanceId); tasks.Add(_db.KeyDeleteAsync(statsKey)); // Reset response times foreach (var opType in new[] { CacheOperationType.Get, CacheOperationType.Set }) { - var responseKey = string.Format(RESPONSE_TIMES_KEY, region, opType, _instanceId); + var responseKey = CacheKeys.DistributedStats.ResponseTimes(region.ToString(), opType.ToString(), _instanceId); tasks.Add(_db.KeyDeleteAsync(responseKey)); } @@ -337,7 +329,7 @@ public async Task ConfigureAlertsAsync(CacheRegion region, CacheAlertThresholds _alertThresholds[region] = thresholds ?? throw new ArgumentNullException(nameof(thresholds)); // Store in Redis for persistence - var alertsKey = string.Format(ALERTS_HASH_KEY, region); + var alertsKey = CacheKeys.DistributedStats.AlertsHash(region.ToString()); var json = JsonSerializer.Serialize(thresholds); await _db.HashSetAsync(alertsKey, "thresholds", json); } @@ -403,17 +395,17 @@ private async Task CalculateAggregatedResponseTimes(CacheStatistics stats, Cache foreach (var instance in instances) { // Get response times - var getKey = string.Format(RESPONSE_TIMES_KEY, region, CacheOperationType.Get, instance); + var getKey = CacheKeys.DistributedStats.ResponseTimes(region.ToString(), CacheOperationType.Get.ToString(), instance); var getEntries = await _db.SortedSetRangeByRankWithScoresAsync(getKey, 0, -1); getTimes.AddRange(getEntries.Select(e => e.Score)); // Set response times - var setKey = string.Format(RESPONSE_TIMES_KEY, region, CacheOperationType.Set, instance); + var setKey = CacheKeys.DistributedStats.ResponseTimes(region.ToString(), CacheOperationType.Set.ToString(), instance); var setEntries = await _db.SortedSetRangeByRankWithScoresAsync(setKey, 0, -1); setTimes.AddRange(setEntries.Select(e => e.Score)); } - if (getTimes.Count() > 0) + if (getTimes.Any()) { getTimes.Sort(); stats.AverageGetTime = TimeSpan.FromMilliseconds(getTimes.Average()); @@ -422,7 +414,7 @@ private async Task CalculateAggregatedResponseTimes(CacheStatistics stats, Cache stats.MaxResponseTime = TimeSpan.FromMilliseconds(getTimes.Max()); } - if (setTimes.Count() > 0) + if (setTimes.Any()) { setTimes.Sort(); stats.AverageSetTime = TimeSpan.FromMilliseconds(setTimes.Average()); @@ -431,7 +423,7 @@ private async Task CalculateAggregatedResponseTimes(CacheStatistics stats, Cache private double GetPercentile(List sortedValues, double percentile) { - if (sortedValues.Count() == 0) return 0; + if (!sortedValues.Any()) return 0; var index = (int)Math.Ceiling(percentile * sortedValues.Count()) - 1; return sortedValues[Math.Max(0, Math.Min(index, sortedValues.Count() - 1))]; @@ -494,7 +486,7 @@ private async Task TriggerAlertAsync(CacheRegion region, CacheAlertType alertTyp // Publish alert var alertMessage = JsonSerializer.Serialize(alert); - await _db.PublishAsync(RedisChannel.Literal(ALERT_CHANNEL), alertMessage); + await _db.PublishAsync(RedisChannel.Literal(CacheKeys.DistributedStats.AlertChannel), alertMessage); // Raise local event AlertTriggered?.Invoke(this, new CacheAlertEventArgs { Alert = alert, IsNew = true }); @@ -512,7 +504,7 @@ private async Task SendHeartbeatAsync() { try { - var heartbeatKey = string.Format(INSTANCE_HEARTBEAT_KEY, _instanceId); + var heartbeatKey = CacheKeys.DistributedStats.Heartbeat(_instanceId); await _db.StringSetAsync(heartbeatKey, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), expiry: _instanceTimeout); } @@ -594,10 +586,35 @@ private string ExportPrometheus(Dictionary allStat return string.Join("\n", lines); } + public async ValueTask DisposeAsync() + { + if (_heartbeatTimer != null) + { + await _heartbeatTimer.DisposeAsync(); + } + + try + { + await UnregisterInstanceAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error unregistering instance during async disposal"); + } + } + public void Dispose() { _heartbeatTimer?.Dispose(); - UnregisterInstanceAsync().GetAwaiter().GetResult(); + + try + { + UnregisterInstanceAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error unregistering instance during disposal"); + } } } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsStore.cs b/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsStore.cs index 922fdf359..9d4ccc698 100644 --- a/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsStore.cs +++ b/Shared/ConduitLLM.Core/Services/RedisCacheStatisticsStore.cs @@ -1,7 +1,8 @@ using System.Text.Json; +using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Models; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; -using ConduitLLM.Core.Models; namespace ConduitLLM.Core.Services { @@ -12,9 +13,6 @@ public class RedisCacheStatisticsStore : ICacheStatisticsStore { private readonly IDistributedCache _cache; private readonly ILogger _logger; - private const string STATS_KEY_PREFIX = "cache:stats:"; - private const string TIMESERIES_KEY_PREFIX = "cache:stats:ts:"; - private const string SNAPSHOT_KEY_PREFIX = "cache:stats:snapshot:"; public RedisCacheStatisticsStore( IDistributedCache cache, @@ -34,7 +32,7 @@ public async Task SaveStatisticsAsync( foreach (var (region, stats) in statistics) { // Save current statistics - var currentKey = $"{STATS_KEY_PREFIX}{region}:current"; + var currentKey = RedisKeys.CacheStats.Current(region.ToString()); var json = JsonSerializer.Serialize(stats); tasks.Add(_cache.SetStringAsync( @@ -47,7 +45,7 @@ public async Task SaveStatisticsAsync( cancellationToken)); // Save time-series data point - var tsKey = $"{TIMESERIES_KEY_PREFIX}{region}:{timestamp:yyyyMMddHHmm}"; + var tsKey = RedisKeys.CacheStats.TimeSeries(region.ToString(), timestamp); tasks.Add(_cache.SetStringAsync( tsKey, json, @@ -60,7 +58,7 @@ public async Task SaveStatisticsAsync( // Save hourly snapshot if (timestamp.Minute == 0) { - var snapshotKey = $"{SNAPSHOT_KEY_PREFIX}{region}:{timestamp:yyyyMMddHH}"; + var snapshotKey = RedisKeys.CacheStats.Snapshot(region.ToString(), timestamp); tasks.Add(_cache.SetStringAsync( snapshotKey, json, @@ -93,7 +91,7 @@ public async Task> LoadAllStatisticsAsy { try { - var key = $"{STATS_KEY_PREFIX}{region}:current"; + var key = RedisKeys.CacheStats.Current(region.ToString()); var json = await _cache.GetStringAsync(key, cancellationToken); if (!string.IsNullOrEmpty(json)) @@ -134,7 +132,7 @@ public async Task GetStatisticsForWindowAsync( while (current <= endTime) { - var tsKey = $"{TIMESERIES_KEY_PREFIX}{region}:{current:yyyyMMddHHmm}"; + var tsKey = RedisKeys.CacheStats.TimeSeries(region.ToString(), current); tasks.Add(_cache.GetStringAsync(tsKey, cancellationToken)); current = current.AddMinutes(1); } @@ -149,7 +147,7 @@ public async Task GetStatisticsForWindowAsync( .Cast() .ToList(); - if (validStats.Count() > 0) + if (validStats.Any()) { // Aggregate statistics aggregated.HitCount = validStats.Sum(s => s.HitCount); @@ -165,7 +163,7 @@ public async Task GetStatisticsForWindowAsync( .Select(s => s.AverageGetTime.TotalMilliseconds) .ToList(); - if (avgGetTimes.Count() > 0) + if (avgGetTimes.Any()) { aggregated.AverageGetTime = TimeSpan.FromMilliseconds(avgGetTimes.Average()); } @@ -177,7 +175,7 @@ public async Task GetStatisticsForWindowAsync( } _logger.LogDebug("Aggregated {DataPoints} data points for region {Region} window {StartTime} to {EndTime}", - validStats.Count() == 0, region, startTime, endTime); + !validStats.Any(), region, startTime, endTime); } catch (Exception ex) { @@ -226,7 +224,7 @@ private async Task LoadFromSnapshots( while (current <= endTime) { - var snapshotKey = $"{SNAPSHOT_KEY_PREFIX}{region}:{current:yyyyMMddHH}"; + var snapshotKey = RedisKeys.CacheStats.Snapshot(region.ToString(), current); try { diff --git a/Shared/ConduitLLM.Core/Services/RedisDistributedLockService.cs b/Shared/ConduitLLM.Core/Services/RedisDistributedLockService.cs index 6804d84fc..46bc0065b 100644 --- a/Shared/ConduitLLM.Core/Services/RedisDistributedLockService.cs +++ b/Shared/ConduitLLM.Core/Services/RedisDistributedLockService.cs @@ -1,7 +1,6 @@ +using ConduitLLM.Core.Constants; using ConduitLLM.Core.Interfaces; - using Microsoft.Extensions.Logging; - using StackExchange.Redis; namespace ConduitLLM.Core.Services @@ -13,7 +12,6 @@ public class RedisDistributedLockService : IDistributedLockService { private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; - private const string LOCK_PREFIX = "lock:"; public RedisDistributedLockService( IConnectionMultiplexer redis, @@ -32,7 +30,7 @@ public RedisDistributedLockService( if (string.IsNullOrWhiteSpace(key)) throw new ArgumentException("Lock key cannot be null or empty", nameof(key)); - var lockKey = $"{LOCK_PREFIX}{key}"; + var lockKey = RedisKeys.Lock.For(key); var lockValue = Guid.NewGuid().ToString(); var db = _redis.GetDatabase(); @@ -97,7 +95,7 @@ public async Task IsLockedAsync(string key, CancellationToken cancellation if (string.IsNullOrWhiteSpace(key)) throw new ArgumentException("Lock key cannot be null or empty", nameof(key)); - var lockKey = $"{LOCK_PREFIX}{key}"; + var lockKey = RedisKeys.Lock.For(key); var db = _redis.GetDatabase(); try diff --git a/Shared/ConduitLLM.Core/Services/RedisEmbeddingCache.cs b/Shared/ConduitLLM.Core/Services/RedisEmbeddingCache.cs index e45fcf479..708534e30 100644 --- a/Shared/ConduitLLM.Core/Services/RedisEmbeddingCache.cs +++ b/Shared/ConduitLLM.Core/Services/RedisEmbeddingCache.cs @@ -3,6 +3,8 @@ using System.Text; using System.Text.Json; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Core.Extensions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; @@ -31,10 +33,6 @@ public class RedisEmbeddingCache : IEmbeddingCache private readonly EmbeddingCacheStats _stats; private readonly object _statsLock = new object(); - private const string CACHE_KEY_PREFIX = "emb:"; - private const string STATS_KEY = "emb:stats"; - private const string MODEL_INDEX_PREFIX = "emb:idx:"; - /// /// Initializes a new instance of the RedisEmbeddingCache. /// @@ -83,7 +81,7 @@ public bool IsAvailable var stopwatch = Stopwatch.StartNew(); try { - var cacheKeyWithPrefix = CACHE_KEY_PREFIX + cacheKey; + var cacheKeyWithPrefix = CacheKeys.Embedding.ByHash(cacheKey); var cachedData = await _database.StringGetAsync(cacheKeyWithPrefix); if (cachedData.HasValue) @@ -137,7 +135,7 @@ public async Task SetEmbeddingAsync(string cacheKey, EmbeddingResponse response, var stopwatch = Stopwatch.StartNew(); try { - var cacheKeyWithPrefix = CACHE_KEY_PREFIX + cacheKey; + var cacheKeyWithPrefix = CacheKeys.Embedding.ByHash(cacheKey); var serializedResponse = JsonSerializer.Serialize(response); var effectiveTtl = ttl ?? _config.DefaultTtl; @@ -147,7 +145,7 @@ public async Task SetEmbeddingAsync(string cacheKey, EmbeddingResponse response, // Add to model index for efficient invalidation if (!string.IsNullOrEmpty(response.Model)) { - var modelIndexKey = MODEL_INDEX_PREFIX + response.Model; + var modelIndexKey = CacheKeys.Embedding.ModelIndex(response.Model); await _database.SetAddAsync(modelIndexKey, cacheKey); await _database.KeyExpireAsync(modelIndexKey, effectiveTtl.Add(TimeSpan.FromMinutes(5))); // Index expires slightly later } @@ -230,13 +228,13 @@ public async Task InvalidateModelCacheAsync(string modelName) try { - var modelIndexKey = MODEL_INDEX_PREFIX + modelName; + var modelIndexKey = CacheKeys.Embedding.ModelIndex(modelName); var cacheKeys = await _database.SetMembersAsync(modelIndexKey); if (cacheKeys.Length > 0) { // Delete all cache entries for this model - var keysToDelete = cacheKeys.Select(key => (RedisKey)(CACHE_KEY_PREFIX + key)).ToArray(); + var keysToDelete = cacheKeys.Select(key => (RedisKey)(CacheKeys.Embedding.ByHash(key.ToString()))).ToArray(); await _database.KeyDeleteAsync(keysToDelete); // Remove the model index @@ -269,7 +267,7 @@ public async Task InvalidateBulkAsync(IEnumerable cacheKeys) try { - var keyArray = cacheKeys.Select(key => (RedisKey)(CACHE_KEY_PREFIX + key)).ToArray(); + var keyArray = cacheKeys.Select(key => (RedisKey)(CacheKeys.Embedding.ByHash(key.ToString()))).ToArray(); if (keyArray.Length > 0) { var deletedCount = await _database.KeyDeleteAsync(keyArray); @@ -314,8 +312,8 @@ public async Task GetStatsAsync() { try { - var pattern = CACHE_KEY_PREFIX + "*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var pattern = CacheKeys.Embedding.Prefix + "*"; + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern, pageSize: 1000).Take(1000); currentStats.EntryCount = keys.Count(); } @@ -339,8 +337,8 @@ public async Task ClearAllAsync() try { - var pattern = CACHE_KEY_PREFIX + "*"; - var server = _database.Multiplexer.GetServer(_database.Multiplexer.GetEndPoints().First()); + var pattern = CacheKeys.Embedding.Prefix + "*"; + var server = _database.Multiplexer.GetPrimaryServer(); var keys = server.Keys(pattern: pattern, pageSize: 1000); var keyArray = keys.Select(key => (RedisKey)key).ToArray(); @@ -359,7 +357,7 @@ public async Task ClearAllAsync() } // Also clear model indexes - var indexPattern = MODEL_INDEX_PREFIX + "*"; + var indexPattern = CacheKeys.Embedding.IndexPrefix + "*"; var indexKeys = server.Keys(pattern: indexPattern, pageSize: 1000); var indexKeyArray = indexKeys.Select(key => (RedisKey)key).ToArray(); if (indexKeyArray.Length > 0) diff --git a/Shared/ConduitLLM.Core/Services/RedisErrorStore.cs b/Shared/ConduitLLM.Core/Services/RedisErrorStore.cs index 4a70b7837..d8155f543 100644 --- a/Shared/ConduitLLM.Core/Services/RedisErrorStore.cs +++ b/Shared/ConduitLLM.Core/Services/RedisErrorStore.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using ConduitLLM.Configuration.Constants; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; using Microsoft.Extensions.Logging; @@ -28,7 +30,7 @@ public RedisErrorStore( public async Task TrackFatalErrorAsync(int keyId, ProviderErrorInfo error) { - var fatalKey = $"provider:errors:key:{keyId}:fatal"; + var fatalKey = CacheKeys.ProviderError.FatalByKey(keyId); var tasks = new List { @@ -43,15 +45,18 @@ public async Task TrackFatalErrorAsync(int keyId, ProviderErrorInfo error) }; // Set first_seen only if it doesn't exist - tasks.Add(_db.HashSetAsync(fatalKey, "first_seen", + tasks.Add(_db.HashSetAsync(fatalKey, "first_seen", error.OccurredAt.ToString("O"), When.NotExists)); - + await Task.WhenAll(tasks); + + // Set TTL of 30 days (same as warnings) to prevent unbounded growth + await _db.KeyExpireAsync(fatalKey, TimeSpan.FromDays(30)); } public async Task TrackWarningAsync(int keyId, ProviderErrorInfo error) { - var warningKey = $"provider:errors:key:{keyId}:warnings"; + var warningKey = CacheKeys.ProviderError.WarningsByKey(keyId); var warningData = JsonSerializer.Serialize(new { type = error.ErrorType.ToString(), @@ -72,7 +77,7 @@ await _db.SortedSetAddAsync(warningKey, public async Task UpdateProviderSummaryAsync(int providerId, bool isFatal) { - var summaryKey = $"provider:errors:provider:{providerId}:summary"; + var summaryKey = CacheKeys.ProviderError.ProviderSummary(providerId); var tasks = new List { @@ -94,7 +99,7 @@ public async Task UpdateProviderSummaryAsync(int providerId, bool isFatal) public async Task AddToGlobalFeedAsync(ProviderErrorInfo error) { - var feedKey = "provider:errors:recent"; + var feedKey = CacheKeys.ProviderError.RecentFeed; var feedEntry = JsonSerializer.Serialize(new { keyId = error.KeyCredentialId, @@ -114,7 +119,7 @@ await _db.SortedSetAddAsync(feedKey, public async Task GetFatalErrorDataAsync(int keyId) { - var fatalKey = $"provider:errors:key:{keyId}:fatal"; + var fatalKey = CacheKeys.ProviderError.FatalByKey(keyId); var data = await _db.HashGetAllAsync(fatalKey); if (data.Length == 0) @@ -126,27 +131,27 @@ await _db.SortedSetAddAsync(feedKey, { ErrorType = dict.GetValueOrDefault("error_type"), Count = int.TryParse(dict.GetValueOrDefault("count"), out var count) ? count : 0, - FirstSeen = dict.TryGetValue("first_seen", out var firstSeen) - ? DateTime.Parse(firstSeen) : null, - LastSeen = dict.TryGetValue("last_seen", out var lastSeen) - ? DateTime.Parse(lastSeen) : null, + FirstSeen = dict.TryGetValue("first_seen", out var firstSeen) + ? DateTime.Parse(firstSeen, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) : null, + LastSeen = dict.TryGetValue("last_seen", out var lastSeen) + ? DateTime.Parse(lastSeen, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) : null, LastErrorMessage = dict.GetValueOrDefault("last_error_message"), - LastStatusCode = int.TryParse(dict.GetValueOrDefault("last_status_code"), out var code) + LastStatusCode = int.TryParse(dict.GetValueOrDefault("last_status_code"), out var code) ? code : null, - DisabledAt = dict.TryGetValue("disabled_at", out var disabledAt) - ? DateTime.Parse(disabledAt) : null + DisabledAt = dict.TryGetValue("disabled_at", out var disabledAt) + ? DateTime.Parse(disabledAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) : null }; } public async Task MarkKeyDisabledAsync(int keyId, DateTime disabledAt) { - var fatalKey = $"provider:errors:key:{keyId}:fatal"; + var fatalKey = CacheKeys.ProviderError.FatalByKey(keyId); await _db.HashSetAsync(fatalKey, "disabled_at", disabledAt.ToString("O")); } public async Task MarkProviderDisabledAsync(int providerId, DateTime disabledAt, string reason) { - var summaryKey = $"provider:errors:provider:{providerId}:summary"; + var summaryKey = CacheKeys.ProviderError.ProviderSummary(providerId); await Task.WhenAll( _db.HashSetAsync(summaryKey, "provider_disabled_at", disabledAt.ToString("O")), _db.HashSetAsync(summaryKey, "provider_disable_reason", reason) @@ -155,23 +160,19 @@ await Task.WhenAll( public async Task AddDisabledKeyToProviderAsync(int providerId, int keyId) { - var summaryKey = $"provider:errors:provider:{providerId}:summary"; - var disabledKeys = await _db.HashGetAsync(summaryKey, "disabled_keys"); - var keyList = disabledKeys.HasValue - ? JsonSerializer.Deserialize>(disabledKeys.ToString()) ?? new List() - : new List(); - - if (!keyList.Contains(keyId)) - { - keyList.Add(keyId); - await _db.HashSetAsync(summaryKey, "disabled_keys", - JsonSerializer.Serialize(keyList)); - } + var setKey = CacheKeys.ProviderError.DisabledKeysByProvider(providerId); + await _db.SetAddAsync(setKey, keyId); + } + + public async Task RemoveDisabledKeyFromProviderAsync(int providerId, int keyId) + { + var setKey = CacheKeys.ProviderError.DisabledKeysByProvider(providerId); + await _db.SetRemoveAsync(setKey, keyId); } public async Task> GetRecentErrorsAsync(int limit = 100) { - var feedKey = "provider:errors:recent"; + var feedKey = CacheKeys.ProviderError.RecentFeed; var entries = await _db.SortedSetRangeByScoreAsync( feedKey, order: Order.Descending, @@ -212,12 +213,12 @@ public async Task> GetErrorCountsByKeysAsync( foreach (var keyId in keyIds) { - var fatalKey = $"provider:errors:key:{keyId}:fatal"; + var fatalKey = CacheKeys.ProviderError.FatalByKey(keyId); var lastSeenValue = await _db.HashGetAsync(fatalKey, "last_seen"); if (lastSeenValue.HasValue) { - var lastSeenTime = DateTime.Parse(lastSeenValue.ToString()); + var lastSeenTime = DateTime.Parse(lastSeenValue.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); if (lastSeenTime >= cutoff) { var countValue = await _db.HashGetAsync(fatalKey, "count"); @@ -236,17 +237,21 @@ public async Task> GetErrorCountsByKeysAsync( return counts; } - public async Task ClearErrorsForKeyAsync(int keyId) + public async Task ClearErrorsForKeyAsync(int keyId, int? providerId = null) { - var keyPrefix = $"provider:errors:key:{keyId}"; - // Delete error keys await _db.KeyDeleteAsync(new RedisKey[] { - $"{keyPrefix}:fatal", - $"{keyPrefix}:warnings" + CacheKeys.ProviderError.FatalByKey(keyId), + CacheKeys.ProviderError.WarningsByKey(keyId) }); - + + // Remove from provider's disabled keys set if providerId is known + if (providerId.HasValue) + { + await RemoveDisabledKeyFromProviderAsync(providerId.Value, keyId); + } + _logger.LogInformation("Cleared errors for key {KeyId}", keyId); } @@ -258,7 +263,7 @@ await _db.KeyDeleteAsync(new RedisKey[] result.FatalError = await GetFatalErrorDataAsync(keyId); // Get recent warnings - var warningKey = $"provider:errors:key:{keyId}:warnings"; + var warningKey = CacheKeys.ProviderError.WarningsByKey(keyId); var warnings = await _db.SortedSetRangeByScoreAsync( warningKey, order: Order.Descending, @@ -287,26 +292,37 @@ await _db.KeyDeleteAsync(new RedisKey[] public async Task GetProviderSummaryAsync(int providerId) { - var summaryKey = $"provider:errors:provider:{providerId}:summary"; - var summaryData = await _db.HashGetAllAsync(summaryKey); - - if (summaryData.Length == 0) + var summaryKey = CacheKeys.ProviderError.ProviderSummary(providerId); + var disabledSetKey = CacheKeys.ProviderError.DisabledKeysByProvider(providerId); + + var summaryTask = _db.HashGetAllAsync(summaryKey); + var disabledKeysTask = _db.SetMembersAsync(disabledSetKey); + + await Task.WhenAll(summaryTask, disabledKeysTask); + + var summaryData = await summaryTask; + var disabledMembers = await disabledKeysTask; + + if (summaryData.Length == 0 && disabledMembers.Length == 0) return null; - + var dict = summaryData.ToDictionary(x => x.Name.ToString(), x => x.Value.ToString()); - + + var disabledKeyIds = disabledMembers + .Where(m => m.HasValue) + .Select(m => (int)m) + .ToList(); + return new ProviderSummaryData { - TotalErrors = int.Parse(dict.GetValueOrDefault("total_errors", "0")), - FatalErrors = int.Parse(dict.GetValueOrDefault("fatal_errors", "0")), - Warnings = int.Parse(dict.GetValueOrDefault("warnings", "0")), - DisabledKeyIds = dict.TryGetValue("disabled_keys", out var keys) - ? JsonSerializer.Deserialize>(keys) ?? new List() - : new List(), + TotalErrors = int.Parse(dict.GetValueOrDefault("total_errors", "0"), CultureInfo.InvariantCulture), + FatalErrors = int.Parse(dict.GetValueOrDefault("fatal_errors", "0"), CultureInfo.InvariantCulture), + Warnings = int.Parse(dict.GetValueOrDefault("warnings", "0"), CultureInfo.InvariantCulture), + DisabledKeyIds = disabledKeyIds, LastError = dict.TryGetValue("last_error", out var lastError) - ? DateTime.Parse(lastError) : null, + ? DateTime.Parse(lastError, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) : null, ProviderDisabledAt = dict.TryGetValue("provider_disabled_at", out var disabledAt) - ? DateTime.Parse(disabledAt) : null, + ? DateTime.Parse(disabledAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) : null, ProviderDisableReason = dict.GetValueOrDefault("provider_disable_reason") }; } @@ -317,7 +333,7 @@ public async Task GetErrorStatisticsAsync(TimeSpan window) var cutoff = DateTime.UtcNow - window; // Get recent errors from feed - var feedKey = "provider:errors:recent"; + var feedKey = CacheKeys.ProviderError.RecentFeed; var entries = await _db.SortedSetRangeByScoreAsync( feedKey, new DateTimeOffset(cutoff).ToUnixTimeSeconds(), @@ -329,14 +345,20 @@ public async Task GetErrorStatisticsAsync(TimeSpan window) { var data = JsonDocument.Parse(entry.ToString()); var errorType = data.RootElement.GetProperty("type").GetString()!; - + stats.TotalErrors++; - + // Count by type if (!stats.ErrorsByType.ContainsKey(errorType)) stats.ErrorsByType[errorType] = 0; stats.ErrorsByType[errorType]++; - + + // Count by provider + var providerId = data.RootElement.GetProperty("providerId").GetInt32(); + if (!stats.ErrorsByProvider.ContainsKey(providerId)) + stats.ErrorsByProvider[providerId] = 0; + stats.ErrorsByProvider[providerId]++; + // Check if fatal var errorTypeEnum = Enum.Parse(errorType); if ((int)errorTypeEnum <= 9) diff --git a/Shared/ConduitLLM.Core/Services/RedisSignalRRateLimitService.cs b/Shared/ConduitLLM.Core/Services/RedisSignalRRateLimitService.cs index 013c44456..18724d9fb 100644 --- a/Shared/ConduitLLM.Core/Services/RedisSignalRRateLimitService.cs +++ b/Shared/ConduitLLM.Core/Services/RedisSignalRRateLimitService.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Constants; using Microsoft.Extensions.Logging; using StackExchange.Redis; @@ -72,44 +73,15 @@ public class RedisSignalRRateLimitService : ISignalRRateLimitService { private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; - - private const string KEY_PREFIX = "signalr:vk:"; - private const string CONN_SUFFIX = ":connections"; - private const string RPM_SUFFIX = ":rpm"; - private const string RPD_SUFFIX = ":rpd"; - - // Lua script for atomic rate limit check with sliding window - private const string CHECK_AND_INCREMENT_SCRIPT = @" - local key = KEYS[1] - local now = tonumber(ARGV[1]) - local window = tonumber(ARGV[2]) - local limit = tonumber(ARGV[3]) - - -- Clean old entries - redis.call('ZREMRANGEBYSCORE', key, 0, now - window) - - -- Count current requests - local current = redis.call('ZCARD', key) - - -- Check limit - if current >= limit then - return {0, current, limit} - end - - -- Add new request - local id = redis.call('INCR', key .. ':seq') - redis.call('ZADD', key, now, now .. ':' .. id) - redis.call('EXPIRE', key, window / 1000 + 60) - - return {1, current + 1, limit} - "; - + private readonly SlidingWindowRateLimiter _slidingWindow; + public RedisSignalRRateLimitService( IConnectionMultiplexer redis, ILogger logger) { _redis = redis ?? throw new ArgumentNullException(nameof(redis)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _slidingWindow = new SlidingWindowRateLimiter(redis, logger); } public async Task CheckMethodInvocationAsync( @@ -122,17 +94,16 @@ public async Task CheckMethodInvocationAsync( return new SignalRRateLimitResult { IsAllowed = true }; } - var db = _redis.GetDatabase(); var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - + // Get current connection count var connectionCount = await GetConnectionCountAsync(virtualKeyHash); - + // Check RPM limit first (more restrictive) if (rpmLimit.HasValue && rpmLimit.Value > 0) { - var rpmKey = $"{KEY_PREFIX}{virtualKeyHash}{RPM_SUFFIX}"; - var rpmResult = await CheckAndIncrementAsync(db, rpmKey, now, 60000, rpmLimit.Value); + var rpmKey = RedisKeys.SignalRRateLimit.Rpm(virtualKeyHash); + var rpmResult = await _slidingWindow.CheckAsync(rpmKey, now, 60000, rpmLimit.Value); if (!rpmResult.IsAllowed) { @@ -165,8 +136,8 @@ public async Task CheckMethodInvocationAsync( // Check RPD limit if (rpdLimit.HasValue && rpdLimit.Value > 0) { - var rpdKey = $"{KEY_PREFIX}{virtualKeyHash}{RPD_SUFFIX}"; - var rpdResult = await CheckAndIncrementAsync(db, rpdKey, now, 86400000, rpdLimit.Value); + var rpdKey = RedisKeys.SignalRRateLimit.Rpd(virtualKeyHash); + var rpdResult = await _slidingWindow.CheckAsync(rpdKey, now, 86400000, rpdLimit.Value); if (!rpdResult.IsAllowed) { @@ -204,36 +175,6 @@ public async Task CheckMethodInvocationAsync( }; } - private async Task CheckAndIncrementAsync( - IDatabase db, - string key, - long now, - int windowMs, - int limit) - { - try - { - var result = await db.ScriptEvaluateAsync( - CHECK_AND_INCREMENT_SCRIPT, - new RedisKey[] { key }, - new RedisValue[] { now, windowMs, limit }); - - var array = (RedisValue[])result!; - return new RateLimitResult - { - IsAllowed = (int)array[0] == 1, - Current = (int)array[1], - Limit = (int)array[2] - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking SignalR rate limit for key {Key}", key); - // Allow on error to prevent total failure - return new RateLimitResult { IsAllowed = true, Current = 0, Limit = limit }; - } - } - public async Task IncrementConnectionCountAsync(string virtualKeyHash) { if (string.IsNullOrEmpty(virtualKeyHash)) @@ -242,7 +183,7 @@ public async Task IncrementConnectionCountAsync(string virtualKeyHash) try { var db = _redis.GetDatabase(); - var key = $"{KEY_PREFIX}{virtualKeyHash}{CONN_SUFFIX}"; + var key = RedisKeys.SignalRRateLimit.Connections(virtualKeyHash); var count = await db.HashIncrementAsync(key, "count"); await db.HashSetAsync(key, "last_connected", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); @@ -268,7 +209,7 @@ public async Task DecrementConnectionCountAsync(string virtualKeyHash) try { var db = _redis.GetDatabase(); - var key = $"{KEY_PREFIX}{virtualKeyHash}{CONN_SUFFIX}"; + var key = RedisKeys.SignalRRateLimit.Connections(virtualKeyHash); var count = await db.HashDecrementAsync(key, "count"); @@ -307,7 +248,7 @@ public async Task GetConnectionCountAsync(string virtualKeyHash) try { var db = _redis.GetDatabase(); - var key = $"{KEY_PREFIX}{virtualKeyHash}{CONN_SUFFIX}"; + var key = RedisKeys.SignalRRateLimit.Connections(virtualKeyHash); var count = await db.HashGetAsync(key, "count"); return count.HasValue ? (int)count : 0; @@ -327,7 +268,7 @@ public async Task CleanupStaleConnectionsAsync(string virtualKeyHash) try { var db = _redis.GetDatabase(); - var key = $"{KEY_PREFIX}{virtualKeyHash}{CONN_SUFFIX}"; + var key = RedisKeys.SignalRRateLimit.Connections(virtualKeyHash); var lastActivity = await db.HashGetAsync(key, "last_disconnected"); if (lastActivity.HasValue) @@ -375,11 +316,5 @@ public async Task CheckConnectionLimitAsync(string virtua }; } - private class RateLimitResult - { - public bool IsAllowed { get; set; } - public int Current { get; set; } - public int Limit { get; set; } - } } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/RedisVirtualKeyRateLimitService.cs b/Shared/ConduitLLM.Core/Services/RedisVirtualKeyRateLimitService.cs index fa3d64c41..bd77cb2a5 100644 --- a/Shared/ConduitLLM.Core/Services/RedisVirtualKeyRateLimitService.cs +++ b/Shared/ConduitLLM.Core/Services/RedisVirtualKeyRateLimitService.cs @@ -1,6 +1,6 @@ +using ConduitLLM.Core.Constants; using Microsoft.Extensions.Logging; using StackExchange.Redis; -using System.Text.Json; namespace ConduitLLM.Core.Services { @@ -61,46 +61,15 @@ public class RedisVirtualKeyRateLimitService : IVirtualKeyRateLimitService { private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; - - private const string KEY_PREFIX = "rate:vk:"; - private const string LIMITS_SUFFIX = ":limits"; - private const string RPM_SUFFIX = ":rpm"; - private const string RPD_SUFFIX = ":rpd"; - - // Lua script for atomic sliding window rate limit check and increment - private const string SLIDING_WINDOW_SCRIPT = @" - local key = KEYS[1] - local now = tonumber(ARGV[1]) - local window = tonumber(ARGV[2]) - local limit = tonumber(ARGV[3]) - - -- Remove old entries outside the window - redis.call('ZREMRANGEBYSCORE', key, 0, now - window) - - -- Count current entries in window - local current = redis.call('ZCARD', key) - - -- Check if limit would be exceeded - if current >= limit then - return {0, current, limit} - end - - -- Add new entry with current timestamp - redis.call('ZADD', key, now, now .. ':' .. redis.call('INCR', key .. ':counter')) - - -- Set expiry to window size + buffer - redis.call('EXPIRE', key, window + 60) - - -- Return allowed, current count + 1, limit - return {1, current + 1, limit} - "; - + private readonly SlidingWindowRateLimiter _slidingWindow; + public RedisVirtualKeyRateLimitService( IConnectionMultiplexer redis, ILogger logger) { _redis = redis ?? throw new ArgumentNullException(nameof(redis)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _slidingWindow = new SlidingWindowRateLimiter(redis, logger); } public async Task CheckRateLimitAsync(string virtualKeyHash, int? rpmLimit, int? rpdLimit) @@ -108,17 +77,16 @@ public async Task CheckRateLimitAsync(string virtualKeyHas if (string.IsNullOrEmpty(virtualKeyHash)) throw new ArgumentException("Virtual key hash cannot be null or empty", nameof(virtualKeyHash)); - var db = _redis.GetDatabase(); var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - + SlidingWindowResult? rpmResult = null; SlidingWindowResult? rpdResult = null; - + // Check RPM limit first (more restrictive) if (rpmLimit.HasValue && rpmLimit.Value > 0) { - var rpmKey = $"{KEY_PREFIX}{virtualKeyHash}{RPM_SUFFIX}"; - rpmResult = await CheckSlidingWindowAsync(db, rpmKey, now, 60000, rpmLimit.Value); // 60 seconds in ms + var rpmKey = RedisKeys.RateLimit.VirtualKeyRpm(virtualKeyHash); + rpmResult = await _slidingWindow.CheckAsync(rpmKey, now, 60000, rpmLimit.Value); // 60 seconds in ms if (!rpmResult.IsAllowed) { @@ -139,8 +107,8 @@ public async Task CheckRateLimitAsync(string virtualKeyHas // Check RPD limit if configured (even if RPM was checked) if (rpdLimit.HasValue && rpdLimit.Value > 0) { - var rpdKey = $"{KEY_PREFIX}{virtualKeyHash}{RPD_SUFFIX}"; - rpdResult = await CheckSlidingWindowAsync(db, rpdKey, now, 86400000, rpdLimit.Value); // 24 hours in ms + var rpdKey = RedisKeys.RateLimit.VirtualKeyRpd(virtualKeyHash); + rpdResult = await _slidingWindow.CheckAsync(rpdKey, now, 86400000, rpdLimit.Value); // 24 hours in ms if (!rpdResult.IsAllowed) { @@ -193,32 +161,6 @@ public async Task CheckRateLimitAsync(string virtualKeyHas }; } - private async Task CheckSlidingWindowAsync(IDatabase db, string key, long now, int windowMs, int limit) - { - try - { - var result = await db.ScriptEvaluateAsync( - SLIDING_WINDOW_SCRIPT, - new RedisKey[] { key }, - new RedisValue[] { now, windowMs, limit }); - - var array = (RedisValue[])result!; - return new SlidingWindowResult - { - IsAllowed = (int)array[0] == 1, - Current = (int)array[1], - Limit = (int)array[2] - }; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error executing sliding window script for key {Key}", key); - // On Redis error, allow the request but log the issue - // This prevents total service failure if Redis is down - return new SlidingWindowResult { IsAllowed = true, Current = 0, Limit = limit }; - } - } - public async Task GetUsageAsync(string virtualKeyHash) { if (string.IsNullOrEmpty(virtualKeyHash)) @@ -234,12 +176,12 @@ public async Task GetUsageAsync(string virtualKeyHash) }; // Get RPM usage - var rpmKey = $"{KEY_PREFIX}{virtualKeyHash}{RPM_SUFFIX}"; + var rpmKey = RedisKeys.RateLimit.VirtualKeyRpm(virtualKeyHash); await db.SortedSetRemoveRangeByScoreAsync(rpmKey, 0, now - 60000); usage.RequestsThisMinute = (int)await db.SortedSetLengthAsync(rpmKey); - + // Get RPD usage - var rpdKey = $"{KEY_PREFIX}{virtualKeyHash}{RPD_SUFFIX}"; + var rpdKey = RedisKeys.RateLimit.VirtualKeyRpd(virtualKeyHash); await db.SortedSetRemoveRangeByScoreAsync(rpdKey, 0, now - 86400000); usage.RequestsToday = (int)await db.SortedSetLengthAsync(rpdKey); @@ -252,7 +194,7 @@ public async Task UpdateRateLimitsAsync(string virtualKeyHash, int? rpmLimit, in throw new ArgumentException("Virtual key hash cannot be null or empty", nameof(virtualKeyHash)); var db = _redis.GetDatabase(); - var limitsKey = $"{KEY_PREFIX}{virtualKeyHash}{LIMITS_SUFFIX}"; + var limitsKey = RedisKeys.RateLimit.VirtualKeyLimits(virtualKeyHash); var transaction = db.CreateTransaction(); @@ -283,22 +225,16 @@ public async Task RemoveRateLimitsAsync(string virtualKeyHash) var db = _redis.GetDatabase(); var transaction = db.CreateTransaction(); - _ = transaction.KeyDeleteAsync($"{KEY_PREFIX}{virtualKeyHash}{LIMITS_SUFFIX}"); - _ = transaction.KeyDeleteAsync($"{KEY_PREFIX}{virtualKeyHash}{RPM_SUFFIX}"); - _ = transaction.KeyDeleteAsync($"{KEY_PREFIX}{virtualKeyHash}{RPD_SUFFIX}"); - _ = transaction.KeyDeleteAsync($"{KEY_PREFIX}{virtualKeyHash}{RPM_SUFFIX}:counter"); - _ = transaction.KeyDeleteAsync($"{KEY_PREFIX}{virtualKeyHash}{RPD_SUFFIX}:counter"); + _ = transaction.KeyDeleteAsync(RedisKeys.RateLimit.VirtualKeyLimits(virtualKeyHash)); + _ = transaction.KeyDeleteAsync(RedisKeys.RateLimit.VirtualKeyRpm(virtualKeyHash)); + _ = transaction.KeyDeleteAsync(RedisKeys.RateLimit.VirtualKeyRpd(virtualKeyHash)); + _ = transaction.KeyDeleteAsync(RedisKeys.RateLimit.VirtualKeyRpmSeq(virtualKeyHash)); + _ = transaction.KeyDeleteAsync(RedisKeys.RateLimit.VirtualKeyRpdSeq(virtualKeyHash)); await transaction.ExecuteAsync(); _logger.LogDebug("Removed all rate limit data for virtual key {KeyHash}", virtualKeyHash); } - private class SlidingWindowResult - { - public bool IsAllowed { get; set; } - public int Current { get; set; } - public int Limit { get; set; } - } } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/RedisWebhookCircuitBreaker.cs b/Shared/ConduitLLM.Core/Services/RedisWebhookCircuitBreaker.cs index de3958c0d..4234e48c2 100644 --- a/Shared/ConduitLLM.Core/Services/RedisWebhookCircuitBreaker.cs +++ b/Shared/ConduitLLM.Core/Services/RedisWebhookCircuitBreaker.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Constants; using Microsoft.Extensions.Logging; using StackExchange.Redis; using System.Text.Json; @@ -12,29 +13,21 @@ namespace ConduitLLM.Core.Services /// - Architecture: docs/architecture/webhook-delivery-system.md /// - Operations: docs/operations/webhook-monitoring.md /// - public class RedisWebhookCircuitBreaker : IWebhookCircuitBreaker + public class RedisWebhookCircuitBreaker : RedisWebhookServiceBase, IWebhookCircuitBreaker { - private readonly IConnectionMultiplexer _redis; - private readonly ILogger _logger; private readonly int _failureThreshold; private readonly TimeSpan _openDuration; private readonly TimeSpan _halfOpenTestInterval; - - private const string CIRCUIT_STATE_KEY = "webhook:circuit:{0}:state"; - private const string FAILURE_COUNT_KEY = "webhook:circuit:{0}:failures"; - private const string SUCCESS_COUNT_KEY = "webhook:circuit:{0}:success"; - private const string LAST_FAILURE_KEY = "webhook:circuit:{0}:lastfail"; - private const string CIRCUIT_OPENED_KEY = "webhook:circuit:{0}:opened"; - + + public RedisWebhookCircuitBreaker( IConnectionMultiplexer redis, ILogger logger, int failureThreshold = 5, TimeSpan? openDuration = null, TimeSpan? halfOpenTestInterval = null) + : base(redis, logger) { - _redis = redis ?? throw new ArgumentNullException(nameof(redis)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _failureThreshold = failureThreshold; _openDuration = openDuration ?? TimeSpan.FromMinutes(5); _halfOpenTestInterval = halfOpenTestInterval ?? TimeSpan.FromSeconds(30); @@ -44,8 +37,8 @@ public bool IsOpen(string webhookUrl) { try { - var db = _redis.GetDatabase(); - var stateKey = string.Format(CIRCUIT_STATE_KEY, GetUrlHash(webhookUrl)); + var db = Redis.GetDatabase(); + var stateKey = RedisKeys.WebhookCircuit.State(GetUrlHash(webhookUrl)); var state = db.StringGet(stateKey); if (state.HasValue) @@ -68,7 +61,7 @@ public bool IsOpen(string webhookUrl) if (transaction.Execute()) { - _logger.LogInformation( + Logger.LogInformation( "Circuit breaker transitioned to half-open for webhook: {WebhookUrl}", webhookUrl); return false; // Allow one test request @@ -83,7 +76,7 @@ public bool IsOpen(string webhookUrl) } catch (Exception ex) { - _logger.LogError(ex, "Error checking circuit state for webhook: {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error checking circuit state for webhook: {WebhookUrl}", webhookUrl); // In case of Redis failure, assume circuit is closed to avoid blocking webhooks return false; } @@ -93,21 +86,21 @@ public void RecordSuccess(string webhookUrl) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var urlHash = GetUrlHash(webhookUrl); var transaction = db.CreateTransaction(); // Reset failure count - transaction.KeyDeleteAsync(string.Format(FAILURE_COUNT_KEY, urlHash)); + transaction.KeyDeleteAsync(RedisKeys.WebhookCircuit.Failures(urlHash)); // Increment success count - var successKey = string.Format(SUCCESS_COUNT_KEY, urlHash); + var successKey = RedisKeys.WebhookCircuit.Successes(urlHash); transaction.StringIncrementAsync(successKey); transaction.KeyExpireAsync(successKey, TimeSpan.FromHours(1)); // Close circuit if it was open or half-open - var stateKey = string.Format(CIRCUIT_STATE_KEY, urlHash); + var stateKey = RedisKeys.WebhookCircuit.State(urlHash); var currentState = db.StringGet(stateKey); if (currentState.HasValue) @@ -117,9 +110,9 @@ public void RecordSuccess(string webhookUrl) { // Close the circuit transaction.KeyDeleteAsync(stateKey); - transaction.KeyDeleteAsync(string.Format(CIRCUIT_OPENED_KEY, urlHash)); + transaction.KeyDeleteAsync(RedisKeys.WebhookCircuit.Opened(urlHash)); - _logger.LogInformation( + Logger.LogInformation( "Circuit breaker closed for webhook: {WebhookUrl} after successful delivery", webhookUrl); } @@ -129,7 +122,7 @@ public void RecordSuccess(string webhookUrl) } catch (Exception ex) { - _logger.LogError(ex, "Error recording success for webhook: {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error recording success for webhook: {WebhookUrl}", webhookUrl); } } @@ -137,11 +130,11 @@ public void RecordFailure(string webhookUrl) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var urlHash = GetUrlHash(webhookUrl); // Check current state - var stateKey = string.Format(CIRCUIT_STATE_KEY, urlHash); + var stateKey = RedisKeys.WebhookCircuit.State(urlHash); var currentState = db.StringGet(stateKey); if (currentState.HasValue) @@ -156,14 +149,14 @@ public void RecordFailure(string webhookUrl) } // Increment failure count atomically - var failureKey = string.Format(FAILURE_COUNT_KEY, urlHash); + var failureKey = RedisKeys.WebhookCircuit.Failures(urlHash); var failureCount = db.StringIncrement(failureKey); // Set expiry on failure counter db.KeyExpire(failureKey, TimeSpan.FromMinutes(15)); // Update last failure time - var lastFailureKey = string.Format(LAST_FAILURE_KEY, urlHash); + var lastFailureKey = RedisKeys.WebhookCircuit.LastFailure(urlHash); db.StringSet(lastFailureKey, DateTime.UtcNow.ToString("O"), TimeSpan.FromHours(1)); // Check if we should open the circuit @@ -174,7 +167,7 @@ public void RecordFailure(string webhookUrl) } catch (Exception ex) { - _logger.LogError(ex, "Error recording failure for webhook: {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error recording failure for webhook: {WebhookUrl}", webhookUrl); } } @@ -182,14 +175,14 @@ public CircuitBreakerStats GetStats(string webhookUrl) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var urlHash = GetUrlHash(webhookUrl); var batch = db.CreateBatch(); - var failureTask = batch.StringGetAsync(string.Format(FAILURE_COUNT_KEY, urlHash)); - var successTask = batch.StringGetAsync(string.Format(SUCCESS_COUNT_KEY, urlHash)); - var lastFailureTask = batch.StringGetAsync(string.Format(LAST_FAILURE_KEY, urlHash)); - var stateTask = batch.StringGetAsync(string.Format(CIRCUIT_STATE_KEY, urlHash)); + var failureTask = batch.StringGetAsync(RedisKeys.WebhookCircuit.Failures(urlHash)); + var successTask = batch.StringGetAsync(RedisKeys.WebhookCircuit.Successes(urlHash)); + var lastFailureTask = batch.StringGetAsync(RedisKeys.WebhookCircuit.LastFailure(urlHash)); + var stateTask = batch.StringGetAsync(RedisKeys.WebhookCircuit.State(urlHash)); batch.Execute(); @@ -226,7 +219,7 @@ public CircuitBreakerStats GetStats(string webhookUrl) } catch (Exception ex) { - _logger.LogError(ex, "Error getting circuit stats for webhook: {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error getting circuit stats for webhook: {WebhookUrl}", webhookUrl); return new CircuitBreakerStats(); } } @@ -243,34 +236,26 @@ private void OpenCircuit(IDatabase db, string webhookUrl, string urlHash, int fa WebhookUrl = webhookUrl }; - var stateKey = string.Format(CIRCUIT_STATE_KEY, urlHash); + var stateKey = RedisKeys.WebhookCircuit.State(urlHash); var stateJson = JsonSerializer.Serialize(circuitState); db.StringSet(stateKey, stateJson, _openDuration.Add(TimeSpan.FromMinutes(5))); // Also set a simple flag for quick checks - var openedKey = string.Format(CIRCUIT_OPENED_KEY, urlHash); + var openedKey = RedisKeys.WebhookCircuit.Opened(urlHash); db.StringSet(openedKey, DateTime.UtcNow.ToString("O"), _openDuration); - _logger.LogWarning( + Logger.LogWarning( "Circuit breaker opened for webhook: {WebhookUrl} after {FailureCount} failures. " + "Will attempt recovery in {OpenDuration} minutes.", webhookUrl, failureCount, _openDuration.TotalMinutes); } catch (Exception ex) { - _logger.LogError(ex, "Error opening circuit for webhook: {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error opening circuit for webhook: {WebhookUrl}", webhookUrl); } } - private string GetUrlHash(string webhookUrl) - { - // Create a consistent hash for the URL to use as Redis key component - using var sha256 = System.Security.Cryptography.SHA256.Create(); - var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(webhookUrl)); - return Convert.ToBase64String(hashBytes).Replace("/", "-").Replace("+", "_").Substring(0, 16); - } - private class CircuitState { public string State { get; set; } = "Closed"; // Closed, Open, HalfOpen diff --git a/Shared/ConduitLLM.Core/Services/RedisWebhookConnectionTracker.cs b/Shared/ConduitLLM.Core/Services/RedisWebhookConnectionTracker.cs index c3a964bed..08facf7a3 100644 --- a/Shared/ConduitLLM.Core/Services/RedisWebhookConnectionTracker.cs +++ b/Shared/ConduitLLM.Core/Services/RedisWebhookConnectionTracker.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Constants; using Microsoft.Extensions.Logging; using StackExchange.Redis; using System.Collections.Concurrent; @@ -44,33 +45,26 @@ public interface IWebhookConnectionTracker /// Redis-based implementation of webhook connection tracking /// Allows distributed tracking of which connections are monitoring which webhooks /// - public class RedisWebhookConnectionTracker : IWebhookConnectionTracker + public class RedisWebhookConnectionTracker : RedisWebhookServiceBase, IWebhookConnectionTracker { - private readonly IConnectionMultiplexer _redis; - private readonly ILogger _logger; - - private const string CONNECTION_WEBHOOKS_KEY = "webhook:connections:{0}:webhooks"; - private const string WEBHOOK_CONNECTIONS_KEY = "webhook:webhooks:{0}:connections"; - private const string CONNECTION_TIMESTAMP_KEY = "webhook:connections:{0}:timestamp"; private const int CONNECTION_EXPIRY_HOURS = 24; - + public RedisWebhookConnectionTracker( IConnectionMultiplexer redis, ILogger logger) + : base(redis, logger) { - _redis = redis ?? throw new ArgumentNullException(nameof(redis)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task AddWebhooksToConnectionAsync(string connectionId, IEnumerable webhookUrls) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var transaction = db.CreateTransaction(); - var connectionKey = string.Format(CONNECTION_WEBHOOKS_KEY, connectionId); - var timestampKey = string.Format(CONNECTION_TIMESTAMP_KEY, connectionId); + var connectionKey = RedisKeys.WebhookConnection.ConnectionWebhooks(connectionId); + var timestampKey = RedisKeys.WebhookConnection.ConnectionTimestamp(connectionId); foreach (var webhookUrl in webhookUrls) { @@ -78,7 +72,7 @@ public async Task AddWebhooksToConnectionAsync(string connectionId, IEnumerable< _ = transaction.SetAddAsync(connectionKey, webhookUrl); // Add connection to webhook's set - var webhookKey = string.Format(WEBHOOK_CONNECTIONS_KEY, GetUrlHash(webhookUrl)); + var webhookKey = RedisKeys.WebhookConnection.WebhookConnections(GetUrlHash(webhookUrl)); _ = transaction.SetAddAsync(webhookKey, connectionId); _ = transaction.KeyExpireAsync(webhookKey, TimeSpan.FromHours(CONNECTION_EXPIRY_HOURS)); } @@ -90,12 +84,12 @@ public async Task AddWebhooksToConnectionAsync(string connectionId, IEnumerable< await transaction.ExecuteAsync(); - _logger.LogDebug("Added {Count} webhooks to connection {ConnectionId}", + Logger.LogDebug("Added {Count} webhooks to connection {ConnectionId}", webhookUrls.Count(), connectionId); } catch (Exception ex) { - _logger.LogError(ex, "Error adding webhooks to connection {ConnectionId}", connectionId); + Logger.LogError(ex, "Error adding webhooks to connection {ConnectionId}", connectionId); } } @@ -103,10 +97,10 @@ public async Task RemoveWebhooksFromConnectionAsync(string connectionId, IEnumer { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var transaction = db.CreateTransaction(); - var connectionKey = string.Format(CONNECTION_WEBHOOKS_KEY, connectionId); + var connectionKey = RedisKeys.WebhookConnection.ConnectionWebhooks(connectionId); foreach (var webhookUrl in webhookUrls) { @@ -114,18 +108,18 @@ public async Task RemoveWebhooksFromConnectionAsync(string connectionId, IEnumer _ = transaction.SetRemoveAsync(connectionKey, webhookUrl); // Remove connection from webhook's set - var webhookKey = string.Format(WEBHOOK_CONNECTIONS_KEY, GetUrlHash(webhookUrl)); + var webhookKey = RedisKeys.WebhookConnection.WebhookConnections(GetUrlHash(webhookUrl)); _ = transaction.SetRemoveAsync(webhookKey, connectionId); } await transaction.ExecuteAsync(); - _logger.LogDebug("Removed {Count} webhooks from connection {ConnectionId}", + Logger.LogDebug("Removed {Count} webhooks from connection {ConnectionId}", webhookUrls.Count(), connectionId); } catch (Exception ex) { - _logger.LogError(ex, "Error removing webhooks from connection {ConnectionId}", connectionId); + Logger.LogError(ex, "Error removing webhooks from connection {ConnectionId}", connectionId); } } @@ -133,15 +127,15 @@ public async Task> GetConnectionWebhooksAsync(string connectionI { try { - var db = _redis.GetDatabase(); - var connectionKey = string.Format(CONNECTION_WEBHOOKS_KEY, connectionId); + var db = Redis.GetDatabase(); + var connectionKey = RedisKeys.WebhookConnection.ConnectionWebhooks(connectionId); var webhooks = await db.SetMembersAsync(connectionKey); return webhooks.Select(w => w.ToString()).ToHashSet(); } catch (Exception ex) { - _logger.LogError(ex, "Error getting webhooks for connection {ConnectionId}", connectionId); + Logger.LogError(ex, "Error getting webhooks for connection {ConnectionId}", connectionId); return new HashSet(); } } @@ -150,15 +144,15 @@ public async Task> GetWebhookConnectionsAsync(string webhookUrl) { try { - var db = _redis.GetDatabase(); - var webhookKey = string.Format(WEBHOOK_CONNECTIONS_KEY, GetUrlHash(webhookUrl)); + var db = Redis.GetDatabase(); + var webhookKey = RedisKeys.WebhookConnection.WebhookConnections(GetUrlHash(webhookUrl)); var connections = await db.SetMembersAsync(webhookKey); return connections.Select(c => c.ToString()).ToHashSet(); } catch (Exception ex) { - _logger.LogError(ex, "Error getting connections for webhook {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error getting connections for webhook {WebhookUrl}", webhookUrl); return new HashSet(); } } @@ -167,10 +161,10 @@ public async Task RemoveConnectionAsync(string connectionId) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); // Get all webhooks for this connection - var connectionKey = string.Format(CONNECTION_WEBHOOKS_KEY, connectionId); + var connectionKey = RedisKeys.WebhookConnection.ConnectionWebhooks(connectionId); var webhooks = await db.SetMembersAsync(connectionKey); if (webhooks.Length > 0) @@ -180,7 +174,7 @@ public async Task RemoveConnectionAsync(string connectionId) // Remove connection from all webhook sets foreach (var webhook in webhooks) { - var webhookKey = string.Format(WEBHOOK_CONNECTIONS_KEY, + var webhookKey = RedisKeys.WebhookConnection.WebhookConnections( GetUrlHash(webhook.ToString())); _ = transaction.SetRemoveAsync(webhookKey, connectionId); } @@ -189,18 +183,18 @@ public async Task RemoveConnectionAsync(string connectionId) _ = transaction.KeyDeleteAsync(connectionKey); // Remove connection timestamp - var timestampKey = string.Format(CONNECTION_TIMESTAMP_KEY, connectionId); + var timestampKey = RedisKeys.WebhookConnection.ConnectionTimestamp(connectionId); _ = transaction.KeyDeleteAsync(timestampKey); await transaction.ExecuteAsync(); } - _logger.LogDebug("Removed connection {ConnectionId} and its {Count} webhook subscriptions", + Logger.LogDebug("Removed connection {ConnectionId} and its {Count} webhook subscriptions", connectionId, webhooks.Length); } catch (Exception ex) { - _logger.LogError(ex, "Error removing connection {ConnectionId}", connectionId); + Logger.LogError(ex, "Error removing connection {ConnectionId}", connectionId); } } @@ -208,26 +202,19 @@ public async Task GetWebhookConnectionCountAsync(string webhookUrl) { try { - var db = _redis.GetDatabase(); - var webhookKey = string.Format(WEBHOOK_CONNECTIONS_KEY, GetUrlHash(webhookUrl)); + var db = Redis.GetDatabase(); + var webhookKey = RedisKeys.WebhookConnection.WebhookConnections(GetUrlHash(webhookUrl)); var count = await db.SetLengthAsync(webhookKey); return (int)count; } catch (Exception ex) { - _logger.LogError(ex, "Error getting connection count for webhook {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error getting connection count for webhook {WebhookUrl}", webhookUrl); return 0; } } - private string GetUrlHash(string webhookUrl) - { - // Create a consistent hash for the URL to use as Redis key component - using var sha256 = System.Security.Cryptography.SHA256.Create(); - var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(webhookUrl)); - return Convert.ToBase64String(hashBytes).Replace("/", "-").Replace("+", "_").Substring(0, 16); - } } /// @@ -239,7 +226,7 @@ public class InMemoryWebhookConnectionTracker : IWebhookConnectionTracker private readonly ConcurrentDictionary> _connectionWebhooks = new(); private readonly ConcurrentDictionary> _webhookConnections = new(); private readonly ILogger _logger; - + public InMemoryWebhookConnectionTracker(ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/Shared/ConduitLLM.Core/Services/RedisWebhookDeliveryTracker.cs b/Shared/ConduitLLM.Core/Services/RedisWebhookDeliveryTracker.cs index b2005055d..5c33a7752 100644 --- a/Shared/ConduitLLM.Core/Services/RedisWebhookDeliveryTracker.cs +++ b/Shared/ConduitLLM.Core/Services/RedisWebhookDeliveryTracker.cs @@ -1,6 +1,7 @@ +using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Interfaces; using Microsoft.Extensions.Logging; using StackExchange.Redis; -using ConduitLLM.Core.Interfaces; namespace ConduitLLM.Core.Services { @@ -8,20 +9,15 @@ namespace ConduitLLM.Core.Services /// Redis-based implementation of webhook delivery tracking /// Provides deduplication and statistics for webhook deliveries /// - public class RedisWebhookDeliveryTracker : IWebhookDeliveryTracker + public class RedisWebhookDeliveryTracker : RedisWebhookServiceBase, IWebhookDeliveryTracker { - private readonly IConnectionMultiplexer _redis; - private readonly ILogger _logger; - private const string DELIVERY_KEY_PREFIX = "webhook:delivered:"; - private const string STATS_KEY_PREFIX = "webhook:stats:"; private const int DELIVERY_KEY_EXPIRY_HOURS = 24; - + public RedisWebhookDeliveryTracker( IConnectionMultiplexer redis, ILogger logger) + : base(redis, logger) { - _redis = redis ?? throw new ArgumentNullException(nameof(redis)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -29,19 +25,19 @@ public async Task IsDeliveredAsync(string deliveryKey) { try { - var db = _redis.GetDatabase(); - var result = await db.KeyExistsAsync($"{DELIVERY_KEY_PREFIX}{deliveryKey}"); + var db = Redis.GetDatabase(); + var result = await db.KeyExistsAsync(RedisKeys.WebhookDelivery.Delivered(deliveryKey)); if (result) { - _logger.LogDebug("Webhook delivery key {DeliveryKey} already exists", deliveryKey); + Logger.LogDebug("Webhook delivery key {DeliveryKey} already exists", deliveryKey); } return result; } catch (Exception ex) { - _logger.LogError(ex, "Error checking webhook delivery status for key {DeliveryKey}", deliveryKey); + Logger.LogError(ex, "Error checking webhook delivery status for key {DeliveryKey}", deliveryKey); // In case of Redis failure, assume not delivered to avoid blocking webhooks return false; } @@ -52,18 +48,18 @@ public async Task MarkDeliveredAsync(string deliveryKey, string webhookUrl) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var transaction = db.CreateTransaction(); // Mark as delivered with expiry var deliveryTimestamp = DateTime.UtcNow.ToString("O"); _ = transaction.StringSetAsync( - $"{DELIVERY_KEY_PREFIX}{deliveryKey}", + RedisKeys.WebhookDelivery.Delivered(deliveryKey), deliveryTimestamp, TimeSpan.FromHours(DELIVERY_KEY_EXPIRY_HOURS)); // Update delivery statistics - var statsKey = $"{STATS_KEY_PREFIX}{webhookUrl}"; + var statsKey = RedisKeys.WebhookDelivery.Stats(webhookUrl); _ = transaction.HashIncrementAsync(statsKey, "delivered", 1); _ = transaction.HashSetAsync(statsKey, "last_delivery", deliveryTimestamp); @@ -74,21 +70,21 @@ public async Task MarkDeliveredAsync(string deliveryKey, string webhookUrl) if (committed) { - _logger.LogInformation( + Logger.LogInformation( "Marked webhook as delivered: {DeliveryKey} to {WebhookUrl}", deliveryKey, webhookUrl); } else { - _logger.LogWarning( + Logger.LogWarning( "Failed to commit webhook delivery transaction for {DeliveryKey}", deliveryKey); } } catch (Exception ex) { - _logger.LogError(ex, + Logger.LogError(ex, "Error marking webhook as delivered: {DeliveryKey} to {WebhookUrl}", deliveryKey, webhookUrl); @@ -101,8 +97,8 @@ public async Task GetStatsAsync(string webhookUrl) { try { - var db = _redis.GetDatabase(); - var statsKey = $"{STATS_KEY_PREFIX}{webhookUrl}"; + var db = Redis.GetDatabase(); + var statsKey = RedisKeys.WebhookDelivery.Stats(webhookUrl); var stats = await db.HashGetAllAsync(statsKey); var deliveredCount = 0L; @@ -145,7 +141,7 @@ public async Task GetStatsAsync(string webhookUrl) } catch (Exception ex) { - _logger.LogError(ex, "Error retrieving webhook stats for {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error retrieving webhook stats for {WebhookUrl}", webhookUrl); return new WebhookDeliveryStats(); } } @@ -155,11 +151,11 @@ public async Task RecordFailureAsync(string deliveryKey, string webhookUrl, stri { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var transaction = db.CreateTransaction(); // Update failure statistics - var statsKey = $"{STATS_KEY_PREFIX}{webhookUrl}"; + var statsKey = RedisKeys.WebhookDelivery.Stats(webhookUrl); var failureTimestamp = DateTime.UtcNow.ToString("O"); _ = transaction.HashIncrementAsync(statsKey, "failed", 1); @@ -170,7 +166,7 @@ public async Task RecordFailureAsync(string deliveryKey, string webhookUrl, stri _ = transaction.KeyExpireAsync(statsKey, TimeSpan.FromDays(30)); // Store failure details with shorter expiry - var failureKey = $"webhook:failure:{deliveryKey}"; + var failureKey = RedisKeys.WebhookDelivery.Failure(deliveryKey); _ = transaction.StringSetAsync( failureKey, $"{failureTimestamp}|{error}", @@ -180,7 +176,7 @@ public async Task RecordFailureAsync(string deliveryKey, string webhookUrl, stri if (committed) { - _logger.LogWarning( + Logger.LogWarning( "Recorded webhook failure: {DeliveryKey} to {WebhookUrl} - {Error}", deliveryKey, webhookUrl, @@ -189,7 +185,7 @@ public async Task RecordFailureAsync(string deliveryKey, string webhookUrl, stri } catch (Exception ex) { - _logger.LogError(ex, + Logger.LogError(ex, "Error recording webhook failure: {DeliveryKey} to {WebhookUrl}", deliveryKey, webhookUrl); diff --git a/Shared/ConduitLLM.Core/Services/RedisWebhookMetricsService.cs b/Shared/ConduitLLM.Core/Services/RedisWebhookMetricsService.cs index 686717291..60268b566 100644 --- a/Shared/ConduitLLM.Core/Services/RedisWebhookMetricsService.cs +++ b/Shared/ConduitLLM.Core/Services/RedisWebhookMetricsService.cs @@ -1,7 +1,9 @@ +using ConduitLLM.Configuration.DTOs.SignalR; +using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Extensions; using Microsoft.Extensions.Logging; using StackExchange.Redis; using System.Collections.Concurrent; -using ConduitLLM.Configuration.DTOs.SignalR; namespace ConduitLLM.Core.Services { @@ -49,33 +51,25 @@ public interface IWebhookMetricsService /// - Architecture: docs/architecture/webhook-delivery-system.md /// - Operations: docs/operations/webhook-monitoring.md /// - public class RedisWebhookMetricsService : IWebhookMetricsService + public class RedisWebhookMetricsService : RedisWebhookServiceBase, IWebhookMetricsService { - private readonly IConnectionMultiplexer _redis; - private readonly ILogger _logger; - - private const string METRICS_KEY_PREFIX = "webhook:metrics:"; - private const string RECENT_EVENTS_KEY = "webhook:events:recent"; - private const string URL_METRICS_HASH = "webhook:metrics:urls:{0}"; - private const string RESPONSE_TIMES_KEY = "webhook:metrics:response:{0}"; private const int MAX_RECENT_EVENTS = 1000; private const int MAX_RESPONSE_TIMES = 100; - + public RedisWebhookMetricsService( IConnectionMultiplexer redis, ILogger logger) + : base(redis, logger) { - _redis = redis ?? throw new ArgumentNullException(nameof(redis)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task RecordAttemptAsync(string webhookUrl, string taskId, string taskType, string eventType) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var urlHash = GetUrlHash(webhookUrl); - var metricsKey = string.Format(URL_METRICS_HASH, urlHash); + var metricsKey = RedisKeys.WebhookMetrics.UrlMetrics(urlHash); var transaction = db.CreateTransaction(); @@ -92,11 +86,11 @@ public async Task RecordAttemptAsync(string webhookUrl, string taskId, string ta await transaction.ExecuteAsync(); - _logger.LogDebug("Recorded delivery attempt for {WebhookUrl}", webhookUrl); + Logger.LogDebug("Recorded delivery attempt for {WebhookUrl}", webhookUrl); } catch (Exception ex) { - _logger.LogError(ex, "Error recording delivery attempt for {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error recording delivery attempt for {WebhookUrl}", webhookUrl); } } @@ -104,9 +98,9 @@ public async Task RecordSuccessAsync(string webhookUrl, string taskId, long resp { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var urlHash = GetUrlHash(webhookUrl); - var metricsKey = string.Format(URL_METRICS_HASH, urlHash); + var metricsKey = RedisKeys.WebhookMetrics.UrlMetrics(urlHash); var transaction = db.CreateTransaction(); @@ -115,7 +109,7 @@ public async Task RecordSuccessAsync(string webhookUrl, string taskId, long resp _ = transaction.HashSetAsync(metricsKey, "last_success", DateTime.UtcNow.ToString("O")); // Store response time in sorted set for percentile calculations - var responseTimesKey = string.Format(RESPONSE_TIMES_KEY, urlHash); + var responseTimesKey = RedisKeys.WebhookMetrics.ResponseTimes(urlHash); _ = transaction.SortedSetAddAsync(responseTimesKey, $"{Guid.NewGuid()}", responseTimeMs); @@ -135,12 +129,12 @@ public async Task RecordSuccessAsync(string webhookUrl, string taskId, long resp await transaction.ExecuteAsync(); - _logger.LogDebug("Recorded successful delivery for {WebhookUrl}, response time: {ResponseTime}ms", + Logger.LogDebug("Recorded successful delivery for {WebhookUrl}, response time: {ResponseTime}ms", webhookUrl, responseTimeMs); } catch (Exception ex) { - _logger.LogError(ex, "Error recording success for {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error recording success for {WebhookUrl}", webhookUrl); } } @@ -148,9 +142,9 @@ public async Task RecordFailureAsync(string webhookUrl, string taskId, bool isPe { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var urlHash = GetUrlHash(webhookUrl); - var metricsKey = string.Format(URL_METRICS_HASH, urlHash); + var metricsKey = RedisKeys.WebhookMetrics.UrlMetrics(urlHash); var transaction = db.CreateTransaction(); @@ -175,12 +169,12 @@ public async Task RecordFailureAsync(string webhookUrl, string taskId, bool isPe await transaction.ExecuteAsync(); - _logger.LogDebug("Recorded failed delivery for {WebhookUrl}, permanent: {IsPermanent}", + Logger.LogDebug("Recorded failed delivery for {WebhookUrl}, permanent: {IsPermanent}", webhookUrl, isPermanent); } catch (Exception ex) { - _logger.LogError(ex, "Error recording failure for {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error recording failure for {WebhookUrl}", webhookUrl); } } @@ -188,7 +182,7 @@ public async Task GetStatisticsAsync(string period = "last_ho { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var stats = new WebhookStatistics { Period = period, @@ -199,8 +193,8 @@ public async Task GetStatisticsAsync(string period = "last_ho var cutoffTime = GetCutoffTime(period); // Get all webhook URL metrics keys - var server = _redis.GetServer(_redis.GetEndPoints().First()); - var keys = server.Keys(pattern: "webhook:metrics:urls:*").ToList(); + var server = Redis.GetPrimaryServer(); + var keys = server.Keys(pattern: RedisKeys.WebhookMetrics.UrlMetricsScanPattern).ToList(); var tasks = new List>(); @@ -243,7 +237,7 @@ public async Task GetStatisticsAsync(string period = "last_ho } catch (Exception ex) { - _logger.LogError(ex, "Error getting webhook statistics for period {Period}", period); + Logger.LogError(ex, "Error getting webhook statistics for period {Period}", period); return new WebhookStatistics { Period = period, UrlStatistics = new List() }; } } @@ -252,9 +246,9 @@ public async Task GetUrlStatisticsAsync(string webhookUrl) { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var urlHash = GetUrlHash(webhookUrl); - var metricsKey = string.Format(URL_METRICS_HASH, urlHash); + var metricsKey = RedisKeys.WebhookMetrics.UrlMetrics(urlHash); var hashEntries = await db.HashGetAllAsync(metricsKey); @@ -282,7 +276,7 @@ public async Task GetUrlStatisticsAsync(string webhookUrl) } // Get percentile response times - var responseTimesKey = string.Format(RESPONSE_TIMES_KEY, urlHash); + var responseTimesKey = RedisKeys.WebhookMetrics.ResponseTimes(urlHash); var p95ResponseTime = await GetPercentileResponseTimeAsync(db, responseTimesKey, 0.95); var p99ResponseTime = await GetPercentileResponseTimeAsync(db, responseTimesKey, 0.99); @@ -302,7 +296,7 @@ public async Task GetUrlStatisticsAsync(string webhookUrl) } catch (Exception ex) { - _logger.LogError(ex, "Error getting statistics for webhook URL {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error getting statistics for webhook URL {WebhookUrl}", webhookUrl); return new WebhookUrlStatistics { Url = webhookUrl, IsHealthy = true }; } } @@ -311,14 +305,14 @@ public async Task AddRecentEventAsync(string webhookUrl, string eventType, long? { try { - var db = _redis.GetDatabase(); + var db = Redis.GetDatabase(); var transaction = db.CreateTransaction(); await AddRecentEventInternalAsync(transaction, webhookUrl, eventType, responseTimeMs, isPermanent); await transaction.ExecuteAsync(); } catch (Exception ex) { - _logger.LogError(ex, "Error adding recent event for {WebhookUrl}", webhookUrl); + Logger.LogError(ex, "Error adding recent event for {WebhookUrl}", webhookUrl); } } @@ -345,14 +339,14 @@ private async Task AddRecentEventInternalAsync( var eventJson = System.Text.Json.JsonSerializer.Serialize(eventData); // Add to sorted set with timestamp as score - _ = transaction.SortedSetAddAsync(RECENT_EVENTS_KEY, eventJson, + _ = transaction.SortedSetAddAsync(RedisKeys.WebhookMetrics.RecentEvents, eventJson, new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds()); // Keep only recent events - _ = transaction.SortedSetRemoveRangeByRankAsync(RECENT_EVENTS_KEY, 0, -MAX_RECENT_EVENTS - 1); + _ = transaction.SortedSetRemoveRangeByRankAsync(RedisKeys.WebhookMetrics.RecentEvents, 0, -MAX_RECENT_EVENTS - 1); // Set expiry - _ = transaction.KeyExpireAsync(RECENT_EVENTS_KEY, TimeSpan.FromDays(1)); + _ = transaction.KeyExpireAsync(RedisKeys.WebhookMetrics.RecentEvents, TimeSpan.FromDays(1)); await Task.CompletedTask; } @@ -452,12 +446,5 @@ private long GetLongValue(Dictionary metrics, string key) return 0; } - private string GetUrlHash(string webhookUrl) - { - // Create a consistent hash for the URL to use as Redis key component - using var sha256 = System.Security.Cryptography.SHA256.Create(); - var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(webhookUrl)); - return Convert.ToBase64String(hashBytes).Replace("/", "-").Replace("+", "_").Substring(0, 16); - } } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/RedisWebhookServiceBase.cs b/Shared/ConduitLLM.Core/Services/RedisWebhookServiceBase.cs new file mode 100644 index 000000000..ac597b6b0 --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/RedisWebhookServiceBase.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace ConduitLLM.Core.Services +{ + /// + /// Base class for Redis-backed webhook services. + /// Consolidates shared constructor pattern and URL hashing. + /// + public abstract class RedisWebhookServiceBase + { + protected readonly IConnectionMultiplexer Redis; + protected readonly ILogger Logger; + + protected RedisWebhookServiceBase( + IConnectionMultiplexer redis, + ILogger logger) + { + Redis = redis ?? throw new ArgumentNullException(nameof(redis)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Creates a consistent, URL-safe hash for use as a Redis key component. + /// + protected static string GetUrlHash(string webhookUrl) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(webhookUrl)); + return Convert.ToBase64String(hashBytes).Replace("/", "-").Replace("+", "_").Substring(0, 16); + } + } +} diff --git a/Shared/ConduitLLM.Core/Services/S3MediaStorageService.Core.cs b/Shared/ConduitLLM.Core/Services/S3MediaStorageService.Core.cs index 0945c2725..a019a8ae9 100644 --- a/Shared/ConduitLLM.Core/Services/S3MediaStorageService.Core.cs +++ b/Shared/ConduitLLM.Core/Services/S3MediaStorageService.Core.cs @@ -26,6 +26,8 @@ public partial class S3MediaStorageService : IMediaStorageService private readonly string _bucketName; private readonly TransferUtility _transferUtility; private readonly ConcurrentDictionary _multipartUploads = new(); + private readonly SemaphoreSlim _initLock = new(1, 1); + private bool _bucketInitialized; public S3MediaStorageService( IOptions options, @@ -90,27 +92,54 @@ public S3MediaStorageService( _s3Client = new AmazonS3Client(_options.AccessKey, _options.SecretKey, config); _transferUtility = new TransferUtility(_s3Client); - // Initialize bucket synchronously to ensure it's ready before first use + // Bucket initialization is now deferred to first use to avoid blocking startup + _logger.LogInformation("S3MediaStorageService initialized (bucket check deferred to first use)"); + } + + /// + /// Ensures the bucket is initialized before use. Thread-safe and only runs once. + /// + private async Task EnsureBucketInitializedAsync() + { + if (_bucketInitialized) + { + return; + } + + await _initLock.WaitAsync(); try { - // Use GetAwaiter().GetResult() to run synchronously during startup - EnsureBucketExistsAsync().GetAwaiter().GetResult(); + if (_bucketInitialized) + { + return; + } + + await EnsureBucketExistsAsync(); + _bucketInitialized = true; _logger.LogInformation("S3 bucket initialization completed successfully"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to initialize S3 bucket. Service will continue but may fail on first upload."); + _logger.LogError(ex, "Failed to initialize S3 bucket"); + throw; + } + finally + { + _initLock.Release(); } } /// public async Task StoreAsync(Stream content, MediaMetadata metadata, IProgress? progress = null) { + // Ensure bucket exists on first use + await EnsureBucketInitializedAsync(); + _logger.LogInformation("StoreAsync called - IsR2: {IsR2}", _options.IsR2); - + // Track if we created a memory stream that needs disposal MemoryStream? memoryStream = null; - + try { // For streaming, we can't compute hash beforehand, so generate a unique key diff --git a/Shared/ConduitLLM.Core/Services/SecurityEventLogger.cs b/Shared/ConduitLLM.Core/Services/SecurityEventLogger.cs index 0992ac084..8681370a1 100644 --- a/Shared/ConduitLLM.Core/Services/SecurityEventLogger.cs +++ b/Shared/ConduitLLM.Core/Services/SecurityEventLogger.cs @@ -330,9 +330,9 @@ public Task GetStatisticsAsync( e.EventType == SecurityEventType.AuthenticationSuccess || e.EventType == SecurityEventType.AuthenticationFailure).ToList(); - if (authEvents.Count() > 0) + if (authEvents.Any()) { - var failures = authEvents.Count(e => + var failures = authEvents.Count(e => e.EventType == SecurityEventType.AuthenticationFailure); stats.AuthenticationFailureRate = (double)failures / authEvents.Count; } diff --git a/Shared/ConduitLLM.Core/Services/SignalRNotificationServiceBase.cs b/Shared/ConduitLLM.Core/Services/SignalRNotificationServiceBase.cs new file mode 100644 index 000000000..7e886eac6 --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/SignalRNotificationServiceBase.cs @@ -0,0 +1,237 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Polly; + +namespace ConduitLLM.Core.Services +{ + /// + /// Base class for SignalR notification services that provides common functionality + /// for sending notifications with optional resilience policies. + /// + /// The SignalR hub type + public abstract class SignalRNotificationServiceBase where THub : Hub + { + /// + /// The SignalR hub context for sending messages + /// + protected readonly IHubContext HubContext; + + /// + /// Logger for recording notification events + /// + protected readonly ILogger Logger; + + /// + /// Optional resilience policy for retry/circuit breaker support + /// + private readonly IAsyncPolicy? _resiliencePolicy; + + /// + /// Initializes a new instance without resilience (simple notifications) + /// + protected SignalRNotificationServiceBase( + IHubContext hubContext, + ILogger logger) + { + HubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _resiliencePolicy = null; + } + + /// + /// Initializes a new instance with an optional resilience policy + /// + protected SignalRNotificationServiceBase( + IHubContext hubContext, + ILogger logger, + IAsyncPolicy? resiliencePolicy) + : this(hubContext, logger) + { + _resiliencePolicy = resiliencePolicy; + } + + /// + /// Sends a notification to a specific group. + /// Handles errors gracefully and logs appropriately. + /// + /// The SignalR group name + /// The hub method name to invoke + /// The notification payload + /// Operation name for logging (auto-filled by caller) + protected async Task SendToGroupAsync( + string groupName, + string methodName, + object payload, + [CallerMemberName] string operationName = "") + { + await ExecuteWithHandlingAsync( + async () => await HubContext.Clients.Group(groupName).SendAsync(methodName, payload), + operationName, + $"group {groupName}"); + } + + /// + /// Sends a notification to a specific group with multiple arguments. + /// + protected async Task SendToGroupAsync( + string groupName, + string methodName, + object[] args, + [CallerMemberName] string operationName = "") + { + await ExecuteWithHandlingAsync( + async () => + { + // Use reflection-based SendCoreAsync for multiple arguments + await HubContext.Clients.Group(groupName).SendCoreAsync(methodName, args); + }, + operationName, + $"group {groupName}"); + } + + /// + /// Sends a notification to all connected clients. + /// + protected async Task SendToAllAsync( + string methodName, + object payload, + [CallerMemberName] string operationName = "") + { + await ExecuteWithHandlingAsync( + async () => await HubContext.Clients.All.SendAsync(methodName, payload), + operationName, + "all clients"); + } + + /// + /// Sends a notification to all connected clients with multiple arguments. + /// + protected async Task SendToAllAsync( + string methodName, + object[] args, + [CallerMemberName] string operationName = "") + { + await ExecuteWithHandlingAsync( + async () => await HubContext.Clients.All.SendCoreAsync(methodName, args), + operationName, + "all clients"); + } + + /// + /// Sends a notification to a specific connection. + /// + protected async Task SendToConnectionAsync( + string connectionId, + string methodName, + object payload, + [CallerMemberName] string operationName = "") + { + await ExecuteWithHandlingAsync( + async () => await HubContext.Clients.Client(connectionId).SendAsync(methodName, payload), + operationName, + $"connection {connectionId}"); + } + + /// + /// Executes a SignalR operation with error handling and optional resilience. + /// + private async Task ExecuteWithHandlingAsync( + Func operation, + string operationName, + string target) + { + try + { + if (_resiliencePolicy != null) + { + await _resiliencePolicy.ExecuteAsync(operation); + } + else + { + await operation(); + } + + Logger.LogDebug("{Operation} notification sent to {Target}", operationName, target); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to send {Operation} notification to {Target}", operationName, target); + // Don't rethrow - notifications should not break the main flow + } + } + + /// + /// Executes a SignalR operation with error handling but rethrows exceptions. + /// Use this when the caller needs to know about failures. + /// + protected async Task ExecuteWithThrowAsync( + Func operation, + string operationName, + string target) + { + try + { + if (_resiliencePolicy != null) + { + await _resiliencePolicy.ExecuteAsync(operation); + } + else + { + await operation(); + } + + Logger.LogDebug("{Operation} notification sent to {Target}", operationName, target); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to send {Operation} notification to {Target}", operationName, target); + throw; + } + } + } + + /// + /// Provides pre-configured resilience policies for SignalR notifications. + /// + public static class SignalRResiliencePolicies + { + /// + /// Creates a standard resilience policy with retry and circuit breaker. + /// + /// Maximum number of retries (default: 3) + /// Number of failures before circuit opens (default: 5) + /// Duration circuit stays open (default: 30 seconds) + public static IAsyncPolicy CreateStandardPolicy( + int maxRetries = 3, + int circuitBreakerThreshold = 5, + TimeSpan? circuitBreakerDuration = null) + { + var retryPolicy = Policy + .Handle() + .WaitAndRetryAsync( + maxRetries, + attempt => TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt - 1))); + + var circuitBreakerPolicy = Policy + .Handle() + .CircuitBreakerAsync( + circuitBreakerThreshold, + circuitBreakerDuration ?? TimeSpan.FromSeconds(30)); + + return Policy.WrapAsync(retryPolicy, circuitBreakerPolicy); + } + + /// + /// Creates a simple retry policy without circuit breaker. + /// + public static IAsyncPolicy CreateRetryOnlyPolicy(int maxRetries = 3) + { + return Policy + .Handle() + .WaitAndRetryAsync( + maxRetries, + attempt => TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt - 1))); + } + } +} diff --git a/Shared/ConduitLLM.Core/Services/SlidingWindowRateLimiter.cs b/Shared/ConduitLLM.Core/Services/SlidingWindowRateLimiter.cs new file mode 100644 index 000000000..5fb73496f --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/SlidingWindowRateLimiter.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace ConduitLLM.Core.Services +{ + /// + /// Result of a sliding window rate limit check. + /// + public class SlidingWindowResult + { + public bool IsAllowed { get; set; } + public int Current { get; set; } + public int Limit { get; set; } + } + + /// + /// Reusable sliding-window rate limiter backed by Redis sorted sets. + /// Uses a Lua script for atomic check-and-increment to prevent race conditions. + /// + public class SlidingWindowRateLimiter + { + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + + // Lua script for atomic sliding window rate limit check and increment. + // Uses sorted sets: timestamp as score, unique member per request. + // KEYS[1] = the sorted set key + // ARGV[1] = current time in milliseconds + // ARGV[2] = window size in milliseconds + // ARGV[3] = max allowed requests in the window + // Returns {isAllowed (0/1), currentCount, limit} + private const string SLIDING_WINDOW_SCRIPT = @" + local key = KEYS[1] + local now = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local limit = tonumber(ARGV[3]) + + -- Remove old entries outside the window + redis.call('ZREMRANGEBYSCORE', key, 0, now - window) + + -- Count current entries in window + local current = redis.call('ZCARD', key) + + -- Check if limit would be exceeded + if current >= limit then + return {0, current, limit} + end + + -- Add new entry with unique member (timestamp:sequence) + redis.call('ZADD', key, now, now .. ':' .. redis.call('INCR', key .. ':seq')) + + -- Set expiry to window size (converted from ms to seconds) + buffer + redis.call('EXPIRE', key, math.ceil(window / 1000) + 60) + + -- Return allowed, current count + 1, limit + return {1, current + 1, limit} + "; + + public SlidingWindowRateLimiter(IConnectionMultiplexer redis, ILogger logger) + { + _redis = redis ?? throw new ArgumentNullException(nameof(redis)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Atomically checks if a request is within the sliding window limit and, + /// if allowed, records it. On Redis failure, allows the request to prevent + /// total service outage. + /// + /// The Redis sorted set key (caller provides the full key including prefix). + /// Current UTC time in milliseconds since epoch. + /// Window size in milliseconds (e.g. 60000 for RPM, 86400000 for RPD). + /// Maximum number of requests allowed in the window. + public async Task CheckAsync(string key, long nowMs, int windowMs, int limit) + { + try + { + var db = _redis.GetDatabase(); + var result = await db.ScriptEvaluateAsync( + SLIDING_WINDOW_SCRIPT, + new RedisKey[] { key }, + new RedisValue[] { nowMs, windowMs, limit }); + + var array = (RedisValue[])result!; + return new SlidingWindowResult + { + IsAllowed = (int)array[0] == 1, + Current = (int)array[1], + Limit = (int)array[2] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing sliding window rate limit script for key {Key}", key); + // On Redis error, allow the request to prevent total service failure + return new SlidingWindowResult { IsAllowed = true, Current = 0, Limit = limit }; + } + } + } +} diff --git a/Shared/ConduitLLM.Core/Services/TiktokenCounter.cs b/Shared/ConduitLLM.Core/Services/TiktokenCounter.cs index 8acb07e3c..95a0d5690 100644 --- a/Shared/ConduitLLM.Core/Services/TiktokenCounter.cs +++ b/Shared/ConduitLLM.Core/Services/TiktokenCounter.cs @@ -62,27 +62,27 @@ public TiktokenCounter(ILogger logger, IModelCapabilityService? } /// - public Task EstimateTokenCountAsync(string modelName, List messages) + public async Task EstimateTokenCountAsync(string modelName, List messages) { - if (messages == null || messages.Count() == 0) + if (messages == null || !messages.Any()) { - return Task.FromResult(0); + return 0; } try { - var encoding = GetEncodingForModel(modelName); + var encoding = await GetEncodingForModelAsync(modelName); if (encoding == null) { // Fallback strategy if we can't get the right encoding _logger.LogWarning("Could not determine encoding for model {ModelName}. Using fallback token estimation method.", modelName); - return Task.FromResult(FallbackEstimateTokens(messages)); + return FallbackEstimateTokens(messages); } int tokenCount = 0; foreach (var message in messages) { - // OpenAI adds tokens per message and per role. + // OpenAI adds tokens per message and per role. // These numbers are based on OpenAI's tokenization approach tokenCount += 4; // Every message follows <|start|>{role/name}\n{content}<|end|>\n @@ -148,73 +148,56 @@ public Task EstimateTokenCountAsync(string modelName, List message tokenCount += 3; // Every reply is primed with <|start|>assistant<|message|> - return Task.FromResult(tokenCount); + return tokenCount; } catch (Exception ex) { _logger.LogError(ex, "Error estimating token count. Using fallback method."); - return Task.FromResult(FallbackEstimateTokens(messages)); + return FallbackEstimateTokens(messages); } } /// - public Task EstimateTokenCountAsync(string modelName, string text) + public async Task EstimateTokenCountAsync(string modelName, string text) { if (string.IsNullOrEmpty(text)) { - return Task.FromResult(0); + return 0; } try { - var encoding = GetEncodingForModel(modelName); + var encoding = await GetEncodingForModelAsync(modelName); if (encoding == null) { // Fallback strategy _logger.LogWarning("Could not determine encoding for model {ModelName}. Using fallback token estimation method.", modelName); - return Task.FromResult(FallbackEstimateTokens(text)); + return FallbackEstimateTokens(text); } try { - return Task.FromResult(encoding.Encode(text).Count); + return encoding.Encode(text).Count; } catch (Exception ex) { _logger.LogWarning(ex, "Error encoding text. Using fallback estimate."); - return Task.FromResult(FallbackEstimateTokens(text)); + return FallbackEstimateTokens(text); } } catch (Exception ex) { _logger.LogError(ex, "Error estimating token count. Using fallback method."); - return Task.FromResult(FallbackEstimateTokens(text)); + return FallbackEstimateTokens(text); } } /// - /// Gets the appropriate TikToken encoding for a given model. + /// Gets the appropriate TikToken encoding for a given model asynchronously. /// /// The name of the model to get encoding for. /// The appropriate TikToken encoding, or null if it cannot be determined. - /// - /// - /// This method determines the appropriate encoding based on the model name using these steps: - /// - /// - /// Identifies the encoding type based on model name patterns - /// Uses a thread-safe caching mechanism to avoid repeatedly creating encodings - /// Falls back to the most modern encoding (cl100k_base) when uncertain - /// - /// - /// The current encoding mappings are: - /// - /// - /// cl100k_base: GPT-3.5 and GPT-4 models - /// p50k_base: Legacy models (davinci, curie, babbage, ada) - /// - /// - private TikToken? GetEncodingForModel(string modelName) + private async Task GetEncodingForModelAsync(string modelName) { try { @@ -225,7 +208,7 @@ public Task EstimateTokenCountAsync(string modelName, string text) { try { - var tokenizerType = _capabilityService.GetTokenizerTypeAsync(modelName).GetAwaiter().GetResult(); + var tokenizerType = await _capabilityService.GetTokenizerTypeAsync(modelName); if (!string.IsNullOrEmpty(tokenizerType)) { encodingName = tokenizerType; @@ -238,63 +221,74 @@ public Task EstimateTokenCountAsync(string modelName, string text) } } - // Map non-OpenAI tokenizer types to their closest OpenAI equivalent - // since TiktokenSharp only supports OpenAI encodings - if (encodingName == "claude" || encodingName == "gemini") - { - // Use cl100k_base as approximation for non-OpenAI models - _logger.LogDebug("Using cl100k_base approximation for {TokenizerType} tokenizer on model {Model}", encodingName, modelName); - encodingName = "cl100k_base"; - } - else if (encodingName == "o200k_base") - { - // o200k_base is newer than cl100k_base, but if not supported, fall back - // Try to use it, but we'll handle the error below if it's not supported - _logger.LogDebug("Attempting to use o200k_base tokenizer for model {Model}", modelName); - } + return GetOrCreateEncoding(encodingName, modelName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in GetEncodingForModelAsync"); + return null; + } + } + + /// + /// Gets or creates a TikToken encoding with thread-safe caching. + /// + /// The name of the encoding to get or create. + /// The model name (for logging purposes). + /// The TikToken encoding, or null if it cannot be created. + private TikToken? GetOrCreateEncoding(string encodingName, string modelName) + { + // Map non-OpenAI tokenizer types to their closest OpenAI equivalent + // since TiktokenSharp only supports OpenAI encodings + if (encodingName == "claude" || encodingName == "gemini") + { + // Use cl100k_base as approximation for non-OpenAI models + _logger.LogDebug("Using cl100k_base approximation for {TokenizerType} tokenizer on model {Model}", encodingName, modelName); + encodingName = "cl100k_base"; + } + else if (encodingName == "o200k_base") + { + // o200k_base is newer than cl100k_base, but if not supported, fall back + // Try to use it, but we'll handle the error below if it's not supported + _logger.LogDebug("Attempting to use o200k_base tokenizer for model {Model}", modelName); + } - lock (_lock) + lock (_lock) + { + if (!_encodings.TryGetValue(encodingName, out var encoding)) { - if (!_encodings.TryGetValue(encodingName, out var encoding)) + try { - try - { - encoding = TikToken.EncodingForModel(encodingName); - _encodings[encodingName] = encoding; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to get encoding {EncodingName} for model {ModelName}, trying cl100k_base fallback", encodingName, modelName); + encoding = TikToken.EncodingForModel(encodingName); + _encodings[encodingName] = encoding; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get encoding {EncodingName} for model {ModelName}, trying cl100k_base fallback", encodingName, modelName); - // Try fallback to cl100k_base if the specific encoding isn't supported - if (encodingName != "cl100k_base") + // Try fallback to cl100k_base if the specific encoding isn't supported + if (encodingName != "cl100k_base") + { + try { - try - { - encodingName = "cl100k_base"; - encoding = TikToken.EncodingForModel(encodingName); - _encodings[encodingName] = encoding; - _logger.LogInformation("Successfully used cl100k_base fallback for model {ModelName}", modelName); - } - catch (Exception fallbackEx) - { - _logger.LogError(fallbackEx, "Failed to get fallback encoding cl100k_base"); - return null; - } + encodingName = "cl100k_base"; + encoding = TikToken.EncodingForModel(encodingName); + _encodings[encodingName] = encoding; + _logger.LogInformation("Successfully used cl100k_base fallback for model {ModelName}", modelName); } - else + catch (Exception fallbackEx) { + _logger.LogError(fallbackEx, "Failed to get fallback encoding cl100k_base"); return null; } } + else + { + return null; + } } - return encoding; } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in GetEncodingForModel"); - return null; + return encoding; } } diff --git a/Shared/ConduitLLM.Core/Services/VideoGenerationOrchestrator.cs b/Shared/ConduitLLM.Core/Services/VideoGenerationOrchestrator.cs index 6ae985df3..2025be024 100644 --- a/Shared/ConduitLLM.Core/Services/VideoGenerationOrchestrator.cs +++ b/Shared/ConduitLLM.Core/Services/VideoGenerationOrchestrator.cs @@ -59,10 +59,12 @@ public VideoGenerationOrchestrator( IHttpClientFactory httpClientFactory, MinimalParameterValidator parameterValidator, MediaGenerationMetrics metrics, + IProviderErrorTrackingService errorTrackingService, ILogger logger) : base(clientFactory, taskService, storageService, publishEndpoint, modelMappingService, virtualKeyService, costService, taskRegistry, - webhookService, httpClientFactory, parameterValidator, metrics, logger) + webhookService, httpClientFactory, parameterValidator, metrics, + errorTrackingService, logger) { _retryConfiguration = retryConfiguration?.Value ?? new VideoGenerationRetryConfiguration(); @@ -87,7 +89,7 @@ protected override async Task ExecuteGenerationAsync( CancellationToken cancellationToken) { // Get the client for the model - var client = _clientFactory.GetClient(modelInfo.ModelAlias); + var client = await _clientFactory.GetClientAsync(modelInfo.ModelAlias, cancellationToken); if (client == null) { throw new NotSupportedException($"No provider available for model {modelInfo.ModelAlias}"); diff --git a/Shared/ConduitLLM.Core/Services/VideoGenerationService.AsyncGeneration.cs b/Shared/ConduitLLM.Core/Services/VideoGenerationService.AsyncGeneration.cs deleted file mode 100644 index 569aba9b8..000000000 --- a/Shared/ConduitLLM.Core/Services/VideoGenerationService.AsyncGeneration.cs +++ /dev/null @@ -1,316 +0,0 @@ -using Microsoft.Extensions.Logging; -using ConduitLLM.Core.Interfaces; -using ConduitLLM.Core.Models; -using ConduitLLM.Core.Events; - -namespace ConduitLLM.Core.Services -{ - /// - /// Partial class containing asynchronous video generation functionality with task management. - /// - public partial class VideoGenerationService - { - /// - public async Task GenerateVideoWithTaskAsync( - VideoGenerationRequest request, - string virtualKey, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("Starting asynchronous video generation task for model {Model}", request.Model); - - // Validate the request - if (!await ValidateRequestAsync(request, cancellationToken)) - { - throw new ArgumentException("Invalid video generation request"); - } - - _logger.LogInformation("Request validated successfully"); - - // Validate virtual key - var virtualKeyInfo = await _virtualKeyService.ValidateVirtualKeyAsync(virtualKey, request.Model); - if (virtualKeyInfo == null || !virtualKeyInfo.IsEnabled) - { - throw new UnauthorizedAccessException("Invalid or disabled virtual key"); - } - - _logger.LogInformation("Virtual key validated: {VirtualKeyId}", virtualKeyInfo.Id); - - // Create task metadata - var taskMetadata = new TaskMetadata(virtualKeyInfo.Id) - { - Model = request.Model, - Prompt = request.Prompt, - ExtensionData = new Dictionary - { - ["VirtualKey"] = virtualKey, - ["Request"] = request - } - }; - - _logger.LogInformation("About to create async task"); - - // Create async task using new overload with explicit virtualKeyId - var taskId = await _taskService.CreateTaskAsync("video_generation", virtualKeyInfo.Id, taskMetadata, cancellationToken); - - _logger.LogInformation("Created task {TaskId}, now publishing VideoGenerationRequested event", taskId); - - // Convert ExtensionData to Dictionary for ProviderOptions - Dictionary? providerOptions = null; - if (request.ExtensionData != null && request.ExtensionData.Count > 0) - { - providerOptions = new Dictionary(); - foreach (var kvp in request.ExtensionData) - { - // Convert JsonElement to appropriate type - providerOptions[kvp.Key] = kvp.Value.ToString(); - } - } - - // Publish VideoGenerationRequested event for async processing - await PublishEventAsync( - new VideoGenerationRequested - { - RequestId = taskId, - Model = request.Model, - Prompt = request.Prompt, - VirtualKeyId = virtualKeyInfo.Id.ToString(), - IsAsync = true, - RequestedAt = DateTime.UtcNow, - CorrelationId = taskId, - WebhookUrl = request.WebhookUrl, - WebhookHeaders = request.WebhookHeaders, - Parameters = new VideoGenerationParameters - { - Size = request.Size, - Duration = request.Duration, - Fps = request.Fps, - Style = request.Style, - ResponseFormat = request.ResponseFormat, - ProviderOptions = providerOptions - } - }, - "async video generation request", - new { Model = request.Model, TaskId = taskId }); - - // Return response with task ID - // Since the existing VideoGenerationResponse doesn't have TaskId/Status fields, - // we'll need to return a standard response with a video data entry containing the task info - return new VideoGenerationResponse - { - Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - Data = new List - { - new VideoData - { - Url = $"pending:{taskId}", // Encode task ID in URL for now - Metadata = new VideoMetadata - { - Width = 0, - Height = 0, - Duration = 0, - Fps = 0, - FileSizeBytes = 0, - MimeType = "application/json" - } - } - }, - Model = request.Model - }; - } - - /// - public async Task GetVideoGenerationStatusAsync( - string taskId, - string virtualKey, - CancellationToken cancellationToken = default) - { - _logger.LogDebug("Checking status for video generation task {TaskId}", taskId); - - // Get task status from the async task service - var taskStatus = await _taskService.GetTaskStatusAsync(taskId, cancellationToken); - - if (taskStatus == null) - { - _logger.LogWarning("Task {TaskId} not found", taskId); - throw new InvalidOperationException($"Task {taskId} not found"); - } - - // Check if the task belongs to the provided virtual key - if (taskStatus.Metadata != null) - { - // Direct access to virtual key ID from typed metadata - // Note: This validation is limited without access to the actual virtual key ID - // In production, you'd want to validate against the actual virtual key ID stored in metadata - _logger.LogDebug("Task {TaskId} has VirtualKeyId: {VirtualKeyId}", taskId, taskStatus.Metadata.VirtualKeyId); - } - else - { - _logger.LogDebug("Task {TaskId} has no metadata", taskId); - } - - // Handle different task states - switch (taskStatus.State) - { - case TaskState.Pending: - case TaskState.Processing: - // Return a response indicating the task is still in progress - return new VideoGenerationResponse - { - Created = ((DateTimeOffset)taskStatus.CreatedAt).ToUnixTimeSeconds(), - Data = new List - { - new VideoData - { - Url = $"pending:{taskId}", - Metadata = new VideoMetadata - { - Width = 0, - Height = 0, - Duration = 0, - Fps = 0, - FileSizeBytes = 0, - MimeType = "application/json" - }, - RevisedPrompt = taskStatus.ProgressMessage ?? $"Video generation in progress... {taskStatus.Progress}%" - } - }, - Model = taskStatus.TaskType == "video_generation" ? "unknown" : taskStatus.TaskType, - Usage = new VideoGenerationUsage - { - VideosGenerated = 0, - TotalDurationSeconds = 0 - } - }; - - case TaskState.Completed: - // Deserialize the result to VideoGenerationResponse - if (taskStatus.Result == null) - { - throw new InvalidOperationException($"Completed task {taskId} has no result"); - } - - try - { - // If the result is already a VideoGenerationResponse, return it directly - if (taskStatus.Result is VideoGenerationResponse response) - { - return response; - } - - // If the result is a JsonElement, deserialize it - if (taskStatus.Result is System.Text.Json.JsonElement resultJson) - { - var videoResponse = System.Text.Json.JsonSerializer.Deserialize( - resultJson.GetRawText(), - new System.Text.Json.JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - if (videoResponse == null) - { - throw new InvalidOperationException($"Failed to deserialize task {taskId} result"); - } - - return videoResponse; - } - - // Try to convert the result to a VideoGenerationResponse - var resultString = System.Text.Json.JsonSerializer.Serialize(taskStatus.Result); - var deserializedResponse = System.Text.Json.JsonSerializer.Deserialize( - resultString, - new System.Text.Json.JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - if (deserializedResponse == null) - { - throw new InvalidOperationException($"Failed to deserialize task {taskId} result"); - } - - return deserializedResponse; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deserialize video generation result for task {TaskId}", taskId); - throw new InvalidOperationException($"Failed to process completed task {taskId} result", ex); - } - - case TaskState.Failed: - // Return an error response or throw an exception - var errorMessage = taskStatus.Error ?? "Video generation failed"; - _logger.LogError("Video generation task {TaskId} failed: {Error}", taskId, errorMessage); - throw new InvalidOperationException($"Video generation failed: {errorMessage}"); - - case TaskState.Cancelled: - _logger.LogInformation("Video generation task {TaskId} was cancelled", taskId); - throw new OperationCanceledException($"Video generation task {taskId} was cancelled"); - - case TaskState.TimedOut: - _logger.LogError("Video generation task {TaskId} timed out", taskId); - throw new TimeoutException($"Video generation task {taskId} timed out"); - - default: - _logger.LogError("Unknown task state {State} for task {TaskId}", taskStatus.State, taskId); - throw new InvalidOperationException($"Unknown task state: {taskStatus.State}"); - } - } - - /// - public async Task CancelVideoGenerationAsync( - string taskId, - string virtualKey, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("Cancelling video generation task {TaskId}", taskId); - - var cancelled = false; - - // Try to cancel via the task registry if available - if (_taskRegistry != null) - { - cancelled = _taskRegistry.TryCancel(taskId); - if (cancelled) - { - _logger.LogInformation("Successfully requested cancellation for task {TaskId} via registry", taskId); - } - else - { - _logger.LogWarning("Task {TaskId} not found in cancellable task registry", taskId); - } - } - - // Update the task status to cancelled - try - { - await _taskService.UpdateTaskStatusAsync( - taskId, - TaskState.Cancelled, - error: "User requested cancellation", - cancellationToken: cancellationToken); - - cancelled = true; - _logger.LogInformation("Updated task {TaskId} status to cancelled", taskId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update task {TaskId} status to cancelled", taskId); - } - - // Publish VideoGenerationCancelled event for distributed systems - await PublishEventAsync( - new VideoGenerationCancelled - { - RequestId = taskId, - CancelledAt = DateTime.UtcNow, - CorrelationId = taskId, - Reason = "User requested cancellation" - }, - "video generation cancellation", - new { TaskId = taskId }); - - return cancelled; - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/VideoGenerationService.SyncGeneration.cs b/Shared/ConduitLLM.Core/Services/VideoGenerationService.SyncGeneration.cs deleted file mode 100644 index 30b394b78..000000000 --- a/Shared/ConduitLLM.Core/Services/VideoGenerationService.SyncGeneration.cs +++ /dev/null @@ -1,270 +0,0 @@ -using Microsoft.Extensions.Logging; -using ConduitLLM.Core.Models; -using ConduitLLM.Core.Events; - -namespace ConduitLLM.Core.Services -{ - /// - /// Partial class containing synchronous video generation functionality. - /// - public partial class VideoGenerationService - { - /// - public async Task GenerateVideoAsync( - VideoGenerationRequest request, - string virtualKey, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("Starting synchronous video generation for model {Model}", request.Model); - - // Validate the request - if (!await ValidateRequestAsync(request, cancellationToken)) - { - throw new ArgumentException("Invalid video generation request"); - } - - // Validate virtual key - var virtualKeyInfo = await _virtualKeyService.ValidateVirtualKeyAsync(virtualKey, request.Model); - if (virtualKeyInfo == null || !virtualKeyInfo.IsEnabled) - { - throw new UnauthorizedAccessException("Invalid or disabled virtual key"); - } - - // Get model mapping to resolve alias to provider model - var modelMapping = await _modelMappingService.GetMappingByModelAliasAsync(request.Model); - if (modelMapping == null) - { - throw new NotSupportedException($"Model {request.Model} is not configured. Please add it to model mappings."); - } - - // Check if the model supports video generation - var supportsVideo = await _capabilityService.SupportsVideoGenerationAsync(request.Model); - if (!supportsVideo) - { - throw new NotSupportedException($"Model {request.Model} does not support video generation"); - } - - // Get the appropriate client for the model - var client = _clientFactory.GetClient(request.Model); - if (client == null) - { - throw new NotSupportedException($"No provider available for model {request.Model}"); - } - - // Store the original model alias for response - var originalModelAlias = request.Model; - - // Update request to use the provider's model ID - request.Model = modelMapping.ProviderModelId; - - // Publish VideoGenerationRequested event - var requestId = Guid.NewGuid().ToString(); - await PublishEventAsync( - new VideoGenerationRequested - { - RequestId = requestId, - Model = request.Model, - Prompt = request.Prompt, - VirtualKeyId = virtualKeyInfo.Id.ToString(), - RequestedAt = DateTime.UtcNow, - CorrelationId = requestId - }, - "video generation request", - new { Model = request.Model, VirtualKeyId = virtualKeyInfo.Id }); - - try - { - // Check if the client supports video generation using reflection - // This avoids circular dependencies while allowing providers to implement video generation - VideoGenerationResponse response; - - var clientType = client.GetType(); - _logger.LogInformation("Client type for model {Model}: {ClientType}", request.Model, clientType.FullName); - - // Check if this is a decorator and try to get the inner client - object clientToCheck = client; - if (clientType.FullName?.Contains("Decorator") == true || clientType.FullName?.Contains("PerformanceTracking") == true) - { - // Try to get the inner client via reflection - var innerClientField = clientType.GetField("_innerClient", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (innerClientField != null) - { - var innerClient = innerClientField.GetValue(client); - if (innerClient != null) - { - clientToCheck = innerClient; - clientType = innerClient.GetType(); - _logger.LogInformation("Unwrapped decorator to inner client type: {InnerClientType}", clientType.FullName); - } - } - } - - // Try to find the method with both nullable and non-nullable string parameter - var createVideoMethod = clientType.GetMethod("CreateVideoAsync", - new[] { typeof(VideoGenerationRequest), typeof(string), typeof(CancellationToken) }) - ?? clientType.GetMethod("CreateVideoAsync", - System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance, - null, - new[] { typeof(VideoGenerationRequest), typeof(string), typeof(CancellationToken) }, - null); - - // If not found, try finding any method with the name and check parameters manually - if (createVideoMethod == null) - { - var methods = clientType.GetMethods() - .Where(m => m.Name == "CreateVideoAsync" && m.GetParameters().Length == 3) - .ToArray(); - - _logger.LogInformation("Found {Count} CreateVideoAsync methods with 3 parameters", methods.Length); - foreach (var method in methods) - { - var parameters = method.GetParameters(); - _logger.LogInformation("Method: {Method}, Parameters: {P1} {P2} {P3}", - method.Name, - parameters[0].ParameterType.Name, - parameters[1].ParameterType.Name, - parameters[2].ParameterType.Name); - } - - if (methods.Length > 0) - { - createVideoMethod = methods[0]; - } - } - - if (createVideoMethod != null) - { - // The client is already configured with the correct API key - var task = createVideoMethod.Invoke(clientToCheck, new object?[] { request, null, cancellationToken }) as Task; - if (task != null) - { - response = await task; - } - else - { - throw new InvalidOperationException($"CreateVideoAsync method on {clientType.Name} did not return expected Task"); - } - } - else - { - _logger.LogError("CreateVideoAsync method not found on client type {ClientType} for model {Model}", - clientType.FullName, request.Model); - - // Log all public methods to help debug - var allMethods = clientType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) - .Where(m => m.Name.Contains("Video")) - .Select(m => m.Name) - .Distinct() - .ToList(); - - _logger.LogError("Available video-related methods on {ClientType}: {Methods}", - clientType.FullName, string.Join(", ", allMethods)); - - throw new NotSupportedException($"Provider for model {request.Model} does not support video generation"); - } - - // Store video in media storage - if (response.Data != null) - { - foreach (var video in response.Data) - { - if (!string.IsNullOrEmpty(video.B64Json)) - { - // Use streaming to decode base64 without loading entire content into memory - using var base64Stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(video.B64Json)); - using var decodedStream = new System.Security.Cryptography.CryptoStream( - base64Stream, - new System.Security.Cryptography.FromBase64Transform(), - System.Security.Cryptography.CryptoStreamMode.Read); - - // Create video metadata for storage - var videoMediaMetadata = new VideoMediaMetadata - { - MediaType = MediaType.Video, - ContentType = video.Metadata?.MimeType ?? "video/mp4", - FileSizeBytes = 0, // Will be set by storage service - FileName = $"video_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.mp4", - Width = video.Metadata?.Width ?? 1280, - Height = video.Metadata?.Height ?? 720, - Duration = video.Metadata?.Duration ?? request.Duration ?? 6, - FrameRate = video.Metadata?.Fps ?? 30, - Codec = video.Metadata?.Codec ?? "h264", - Bitrate = video.Metadata?.Bitrate, - GeneratedByModel = request.Model, - GenerationPrompt = request.Prompt, - Resolution = request.Size ?? "1280x720" - }; - - var storageResult = await _mediaStorage.StoreVideoAsync(decodedStream, videoMediaMetadata); - video.Url = storageResult.Url; - video.B64Json = null; // Clear base64 data after storing - - // Publish MediaGenerationCompleted event for lifecycle tracking - await PublishEventAsync(new MediaGenerationCompleted - { - MediaType = MediaType.Video, - VirtualKeyId = virtualKeyInfo.Id, - MediaUrl = storageResult.Url, - StorageKey = storageResult.StorageKey, - FileSizeBytes = videoMediaMetadata.FileSizeBytes, - ContentType = videoMediaMetadata.ContentType, - GeneratedByModel = request.Model, - GenerationPrompt = request.Prompt, - GeneratedAt = DateTime.UtcNow, - Metadata = new Dictionary - { - ["width"] = videoMediaMetadata.Width, - ["height"] = videoMediaMetadata.Height, - ["duration"] = videoMediaMetadata.Duration, - ["frameRate"] = videoMediaMetadata.FrameRate, - ["resolution"] = videoMediaMetadata.Resolution - }, - CorrelationId = requestId - }, "media generation completed", new { MediaType = "Video", Model = request.Model }); - } - } - } - - // Update spend - var cost = await EstimateCostAsync(request, cancellationToken); - await _virtualKeyService.UpdateSpendAsync(virtualKeyInfo.Id, cost); - - // Publish VideoGenerationCompleted event - await PublishEventAsync(new VideoGenerationCompleted - { - RequestId = requestId, - VideoUrl = response.Data?.FirstOrDefault()?.Url ?? string.Empty, - CompletedAt = DateTime.UtcNow, - CorrelationId = requestId - }, "video generation completed", new { Model = originalModelAlias }); - - // Restore the original model alias in the response - if (response.Model != null) - { - response.Model = originalModelAlias; - } - - return response; - } - catch (Exception ex) - { - _logger.LogError(ex, "Video generation failed for model {Model}", request.Model); - - // Publish VideoGenerationFailed event - await PublishEventAsync( - new VideoGenerationFailed - { - RequestId = requestId, - Error = ex.Message, - FailedAt = DateTime.UtcNow, - CorrelationId = requestId - }, - "video generation failure", - new { Model = request.Model, Error = ex.Message }); - - throw; - } - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/VideoGenerationService.Validation.cs b/Shared/ConduitLLM.Core/Services/VideoGenerationService.Validation.cs deleted file mode 100644 index ca074cf02..000000000 --- a/Shared/ConduitLLM.Core/Services/VideoGenerationService.Validation.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.Extensions.Logging; -using ConduitLLM.Core.Models; - -namespace ConduitLLM.Core.Services -{ - /// - /// Partial class containing validation and cost estimation functionality for video generation. - /// - public partial class VideoGenerationService - { - /// - public async Task ValidateRequestAsync( - VideoGenerationRequest request, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(request.Prompt)) - { - _logger.LogWarning("Video generation request validation failed: empty prompt"); - return false; - } - - if (string.IsNullOrWhiteSpace(request.Model)) - { - _logger.LogWarning("Video generation request validation failed: empty model"); - return false; - } - - // Check if model supports video generation - var supportsVideo = await _capabilityService.SupportsVideoGenerationAsync(request.Model); - if (!supportsVideo) - { - _logger.LogWarning("Model {Model} does not support video generation", request.Model); - return false; - } - - // Validate duration if specified - if (request.Duration.HasValue && (request.Duration.Value < 1 || request.Duration.Value > 60)) - { - _logger.LogWarning("Invalid video duration: {Duration}", request.Duration); - return false; - } - - // Validate FPS if specified - if (request.Fps.HasValue && (request.Fps.Value < 1 || request.Fps.Value > 120)) - { - _logger.LogWarning("Invalid video FPS: {Fps}", request.Fps); - return false; - } - - return true; - } - - /// - public async Task EstimateCostAsync( - VideoGenerationRequest request, - CancellationToken cancellationToken = default) - { - // Create usage object for cost calculation - var usage = new Usage - { - PromptTokens = 0, - CompletionTokens = 0, - TotalTokens = 0, - VideoDurationSeconds = request.Duration ?? 6, // Default 6 seconds - VideoResolution = request.Size ?? "1280x720" - }; - - // Use the cost calculation service - return await _costService.CalculateCostAsync(request.Model, usage, cancellationToken); - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/VideoGenerationService.cs b/Shared/ConduitLLM.Core/Services/VideoGenerationService.cs deleted file mode 100644 index 994cf185c..000000000 --- a/Shared/ConduitLLM.Core/Services/VideoGenerationService.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.Extensions.Logging; -using ConduitLLM.Core.Interfaces; -using MassTransit; - -using ConduitLLM.Configuration.Interfaces; -using IVirtualKeyService = ConduitLLM.Core.Interfaces.IVirtualKeyService; - -namespace ConduitLLM.Core.Services -{ - /// - /// Base implementation of the video generation service. - /// Coordinates video generation across different providers and handles orchestration. - /// - public partial class VideoGenerationService : EventPublishingServiceBase, IVideoGenerationService - { - private readonly ILLMClientFactory _clientFactory; - private readonly IModelCapabilityService _capabilityService; - private readonly ICostCalculationService _costService; - private readonly IVirtualKeyService _virtualKeyService; - private readonly IMediaStorageService _mediaStorage; - private readonly IAsyncTaskService _taskService; - private readonly ICancellableTaskRegistry? _taskRegistry; - private readonly ILogger _logger; - private readonly IModelProviderMappingService _modelMappingService; - - public VideoGenerationService( - ILLMClientFactory clientFactory, - IModelCapabilityService capabilityService, - ICostCalculationService costService, - IVirtualKeyService virtualKeyService, - IMediaStorageService mediaStorage, - IAsyncTaskService taskService, - ILogger logger, - IModelProviderMappingService modelMappingService, - IPublishEndpoint? publishEndpoint = null, - ICancellableTaskRegistry? taskRegistry = null) - : base(publishEndpoint, logger) - { - _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); - _capabilityService = capabilityService ?? throw new ArgumentNullException(nameof(capabilityService)); - _costService = costService ?? throw new ArgumentNullException(nameof(costService)); - _virtualKeyService = virtualKeyService ?? throw new ArgumentNullException(nameof(virtualKeyService)); - _mediaStorage = mediaStorage ?? throw new ArgumentNullException(nameof(mediaStorage)); - _taskService = taskService ?? throw new ArgumentNullException(nameof(taskService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _taskRegistry = taskRegistry; - _modelMappingService = modelMappingService ?? throw new ArgumentNullException(nameof(modelMappingService)); - - // Log event publishing configuration - LogEventPublishingConfiguration(nameof(VideoGenerationService)); - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Core/Services/VirtualKeyServiceBase.cs b/Shared/ConduitLLM.Core/Services/VirtualKeyServiceBase.cs new file mode 100644 index 000000000..0c8098b9d --- /dev/null +++ b/Shared/ConduitLLM.Core/Services/VirtualKeyServiceBase.cs @@ -0,0 +1,324 @@ +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Configuration.DTOs.VirtualKey; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Enums; +using ConduitLLM.Configuration.Extensions; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Events; +using ConduitLLM.Core.Extensions; + +using MassTransit; +using Microsoft.Extensions.Logging; +using VirtualKeyUtilities = ConduitLLM.Configuration.Utilities.VirtualKeyUtilities; + +namespace ConduitLLM.Core.Services +{ + /// + /// Base class for virtual key services providing shared CRUD operations with + /// event publishing and extensibility hooks for caching and media cleanup. + /// + public abstract class VirtualKeyServiceBase : EventPublishingServiceBase + { + protected readonly IVirtualKeyRepository VirtualKeyRepository; + protected readonly IVirtualKeyGroupRepository GroupRepository; + protected readonly IVirtualKeySpendHistoryRepository SpendHistoryRepository; + + protected VirtualKeyServiceBase( + IVirtualKeyRepository virtualKeyRepository, + IVirtualKeyGroupRepository groupRepository, + IVirtualKeySpendHistoryRepository spendHistoryRepository, + IPublishEndpoint? publishEndpoint, + ILogger logger) + : base(publishEndpoint, logger) + { + VirtualKeyRepository = virtualKeyRepository ?? throw new ArgumentNullException(nameof(virtualKeyRepository)); + GroupRepository = groupRepository ?? throw new ArgumentNullException(nameof(groupRepository)); + SpendHistoryRepository = spendHistoryRepository ?? throw new ArgumentNullException(nameof(spendHistoryRepository)); + } + + #region Virtual Hooks + + /// Called after a virtual key is created and saved to the database. + protected virtual Task OnVirtualKeyCreatedAsync(VirtualKey key) => Task.CompletedTask; + + /// Called after a virtual key is updated. Subclasses can use this for cache invalidation. + protected virtual Task OnVirtualKeyUpdatedAsync(VirtualKey key, string[] changedProperties) => Task.CompletedTask; + + /// Called before a virtual key is deleted. Subclasses can use this for media cleanup. + protected virtual Task OnBeforeVirtualKeyDeleteAsync(int keyId) => Task.CompletedTask; + + /// Called after a virtual key is deleted. Subclasses can use this for cache invalidation. + protected virtual Task OnVirtualKeyDeletedAsync(VirtualKey key) => Task.CompletedTask; + + #endregion + + #region CRUD Operations + + public virtual async Task GenerateVirtualKeyAsync(CreateVirtualKeyRequestDto request) + { + var keyValue = VirtualKeyUtilities.GenerateSecureKey(); + var keyWithPrefix = VirtualKeyConstants.KeyPrefix + keyValue; + var keyHash = VirtualKeyUtilities.HashKey(keyWithPrefix); + + // Verify the group exists + var existingGroup = await GroupRepository.GetByIdAsync(request.VirtualKeyGroupId); + if (existingGroup == null) + { + throw new InvalidOperationException( + $"Virtual key group {request.VirtualKeyGroupId} not found. Ensure the group exists before creating keys."); + } + + if (existingGroup.Balance <= 0) + { + Logger.LogWarning( + "Virtual key group {GroupId} has zero balance. Keys in this group cannot make API calls until funded.", + request.VirtualKeyGroupId); + } + + var virtualKey = new VirtualKey + { + KeyName = request.KeyName ?? string.Empty, + KeyHash = keyHash, + AllowedModels = request.AllowedModels, + VirtualKeyGroupId = existingGroup.Id, + IsEnabled = true, + ExpiresAt = request.ExpiresAt, + Metadata = request.Metadata, + RateLimitRpm = request.RateLimitRpm, + RateLimitRpd = request.RateLimitRpd, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + var createdId = await VirtualKeyRepository.CreateAsync(virtualKey); + + virtualKey = await VirtualKeyRepository.GetByIdAsync(createdId); + if (virtualKey == null) + { + throw new InvalidOperationException($"Failed to retrieve newly created virtual key with ID {createdId}"); + } + + Logger.LogInformation("Created new virtual key: {KeyName} (ID: {KeyId})", + LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id); + + await PublishEventAsync( + new VirtualKeyCreated + { + KeyId = virtualKey.Id, + KeyHash = virtualKey.KeyHash, + KeyName = virtualKey.KeyName, + CreatedAt = virtualKey.CreatedAt, + IsEnabled = virtualKey.IsEnabled, + AllowedModels = virtualKey.AllowedModels, + VirtualKeyGroupId = virtualKey.VirtualKeyGroupId, + CorrelationId = Guid.NewGuid().ToString() + }, + $"create virtual key {virtualKey.Id}", + new { KeyName = virtualKey.KeyName }); + + await OnVirtualKeyCreatedAsync(virtualKey); + + return new CreateVirtualKeyResponseDto + { + VirtualKey = keyWithPrefix, + KeyInfo = VirtualKeyUtilities.MapToDto(virtualKey) + }; + } + + public virtual async Task GetVirtualKeyInfoAsync(int id) + { + var virtualKey = await VirtualKeyRepository.GetByIdAsync(id); + if (virtualKey == null) + { + Logger.LogWarning("Virtual key with ID {KeyId} not found", id); + return null; + } + + return VirtualKeyUtilities.MapToDto(virtualKey); + } + + public virtual async Task> ListVirtualKeysAsync() + { + var virtualKeys = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + VirtualKeyRepository.GetPaginatedAsync); + Logger.LogDebug("Listed {Count} virtual keys", virtualKeys.Count); + return [.. virtualKeys.Select(VirtualKeyUtilities.MapToDto)]; + } + + public virtual async Task UpdateVirtualKeyAsync(int id, UpdateVirtualKeyRequestDto request) + { + var key = await VirtualKeyRepository.GetByIdAsync(id); + if (key == null) + { + Logger.LogWarning("Virtual key with ID {KeyId} not found for update", id); + return false; + } + + // Track actual changes + var changedProperties = new List(); + + if (request.KeyName != null && key.KeyName != request.KeyName) + { + key.KeyName = request.KeyName; + changedProperties.Add(nameof(key.KeyName)); + } + + if (request.AllowedModels != null && key.AllowedModels != request.AllowedModels) + { + key.AllowedModels = string.IsNullOrEmpty(request.AllowedModels) ? null : request.AllowedModels; + changedProperties.Add(nameof(key.AllowedModels)); + } + + if (request.VirtualKeyGroupId.HasValue && key.VirtualKeyGroupId != request.VirtualKeyGroupId.Value) + { + var newGroup = await GroupRepository.GetByIdAsync(request.VirtualKeyGroupId.Value); + if (newGroup == null) + { + throw new InvalidOperationException( + $"Virtual key group with ID {request.VirtualKeyGroupId.Value} not found"); + } + key.VirtualKeyGroupId = request.VirtualKeyGroupId.Value; + changedProperties.Add(nameof(key.VirtualKeyGroupId)); + } + + if (request.IsEnabled.HasValue && key.IsEnabled != request.IsEnabled.Value) + { + key.IsEnabled = request.IsEnabled.Value; + changedProperties.Add(nameof(key.IsEnabled)); + } + + if (request.ExpiresAt.HasValue && key.ExpiresAt != request.ExpiresAt) + { + key.ExpiresAt = request.ExpiresAt; + changedProperties.Add(nameof(key.ExpiresAt)); + } + + if (request.Metadata != null && key.Metadata != request.Metadata) + { + key.Metadata = string.IsNullOrEmpty(request.Metadata) ? null : request.Metadata; + changedProperties.Add(nameof(key.Metadata)); + } + + if (request.RateLimitRpm.HasValue && key.RateLimitRpm != request.RateLimitRpm) + { + key.RateLimitRpm = request.RateLimitRpm; + changedProperties.Add(nameof(key.RateLimitRpm)); + } + + if (request.RateLimitRpd.HasValue && key.RateLimitRpd != request.RateLimitRpd) + { + key.RateLimitRpd = request.RateLimitRpd; + changedProperties.Add(nameof(key.RateLimitRpd)); + } + + if (!changedProperties.Any()) + { + Logger.LogDebug("No changes detected for virtual key {KeyId} โ€” skipping update", id); + return true; + } + + key.UpdatedAt = DateTime.UtcNow; + var success = await VirtualKeyRepository.UpdateAsync(key); + + if (success) + { + var changed = changedProperties.ToArray(); + + await PublishEventAsync( + new VirtualKeyUpdated + { + KeyId = key.Id, + KeyHash = key.KeyHash, + ChangedProperties = changed, + CorrelationId = Guid.NewGuid().ToString() + }, + $"update virtual key {id}", + new { ChangedProperties = string.Join(", ", changed) }); + + await OnVirtualKeyUpdatedAsync(key, changed); + + Logger.LogInformation("Updated virtual key {KeyId} ({KeyName}), changed: [{ChangedProperties}]", + id, LoggingSanitizer.S(key.KeyName), string.Join(", ", changed)); + } + + return success; + } + + public virtual async Task DeleteVirtualKeyAsync(int id) + { + var key = await VirtualKeyRepository.GetByIdAsync(id); + if (key == null) + { + Logger.LogWarning("Virtual key with ID {KeyId} not found for deletion", id); + return false; + } + + await OnBeforeVirtualKeyDeleteAsync(id); + + var success = await VirtualKeyRepository.DeleteAsync(id); + + if (success) + { + await PublishEventAsync( + new VirtualKeyDeleted + { + KeyId = key.Id, + KeyHash = key.KeyHash, + KeyName = key.KeyName, + CorrelationId = Guid.NewGuid().ToString() + }, + $"delete virtual key {key.Id}", + new { KeyName = key.KeyName }); + + await OnVirtualKeyDeletedAsync(key); + + Logger.LogInformation("Deleted virtual key {KeyId} ({KeyName})", + id, LoggingSanitizer.S(key.KeyName)); + } + + return success; + } + + public virtual async Task ResetSpendAsync(int id) + { + var virtualKey = await VirtualKeyRepository.GetByIdAsync(id); + if (virtualKey == null) return false; + + var group = await GroupRepository.GetByIdAsync(virtualKey.VirtualKeyGroupId); + if (group == null) + { + Logger.LogError("Virtual key {KeyId} has invalid group ID {GroupId}", + id, virtualKey.VirtualKeyGroupId); + return false; + } + + if (group.LifetimeSpent > 0) + { + var spendHistory = new VirtualKeySpendHistory + { + VirtualKeyId = virtualKey.Id, + Amount = group.LifetimeSpent, + Date = DateTime.UtcNow + }; + await SpendHistoryRepository.CreateAsync(spendHistory); + + await GroupRepository.AdjustBalanceAsync( + group.Id, + group.LifetimeSpent, + $"Spend reset for virtual key #{virtualKey.Id}", + "System", + ReferenceType.System, + virtualKey.Id.ToString()); + + group.LifetimeSpent = 0; + group.UpdatedAt = DateTime.UtcNow; + await GroupRepository.UpdateAsync(group); + } + + virtualKey.UpdatedAt = DateTime.UtcNow; + return await VirtualKeyRepository.UpdateAsync(virtualKey); + } + + #endregion + } +} diff --git a/Services/ConduitLLM.Gateway/Services/VirtualKeyValidationHelper.cs b/Shared/ConduitLLM.Core/Services/VirtualKeyValidationHelper.cs similarity index 93% rename from Services/ConduitLLM.Gateway/Services/VirtualKeyValidationHelper.cs rename to Shared/ConduitLLM.Core/Services/VirtualKeyValidationHelper.cs index af91129b4..9a8087d3d 100644 --- a/Services/ConduitLLM.Gateway/Services/VirtualKeyValidationHelper.cs +++ b/Shared/ConduitLLM.Core/Services/VirtualKeyValidationHelper.cs @@ -3,7 +3,7 @@ using ConduitLLM.Configuration.Interfaces; using Microsoft.Extensions.Logging; -namespace ConduitLLM.Gateway.Services +namespace ConduitLLM.Core.Services { /// /// Helper class containing shared virtual key validation logic @@ -29,7 +29,7 @@ public static async Task ValidateVirtualKeyAsync( // Check if key is enabled if (!virtualKey.IsEnabled) { - logger.LogWarning("Virtual key is disabled: {KeyName} (ID: {KeyId})", + logger.LogWarning("Virtual key is disabled: {KeyName} (ID: {KeyId})", LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id); return new ValidationResult { IsValid = false, Reason = "Key is disabled" }; } @@ -50,10 +50,10 @@ public static async Task ValidateVirtualKeyAsync( { logger.LogWarning("Virtual key group budget depleted: {KeyName} (ID: {KeyId}), group {GroupId} has balance {Balance}", LoggingSanitizer.S(virtualKey.KeyName), virtualKey.Id, group.Id, group.Balance); - - return new ValidationResult - { - IsValid = false, + + return new ValidationResult + { + IsValid = false, Reason = "Insufficient balance", StatusCode = 402 // Payment Required }; @@ -63,7 +63,7 @@ public static async Task ValidateVirtualKeyAsync( // Check if model is allowed if (!string.IsNullOrEmpty(requestedModel) && !string.IsNullOrEmpty(virtualKey.AllowedModels)) { - bool isModelAllowed = VirtualKeyUtilities.IsModelAllowed(requestedModel, virtualKey.AllowedModels); + bool isModelAllowed = ConduitLLM.Configuration.Utilities.VirtualKeyUtilities.IsModelAllowed(requestedModel, virtualKey.AllowedModels); if (!isModelAllowed) { logger.LogWarning("Virtual key {KeyName} (ID: {KeyId}) attempted to access restricted model: {RequestedModel}", @@ -97,16 +97,16 @@ public class ValidationResult /// Whether the validation passed /// public bool IsValid { get; set; } - + /// /// Reason for validation failure /// public string? Reason { get; set; } - + /// /// Optional status code to return (e.g., 402 for insufficient balance) /// public int? StatusCode { get; set; } } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Core/Utilities/FileHelper.cs b/Shared/ConduitLLM.Core/Utilities/FileHelper.cs index 31d8e7c25..a503dc29d 100644 --- a/Shared/ConduitLLM.Core/Utilities/FileHelper.cs +++ b/Shared/ConduitLLM.Core/Utilities/FileHelper.cs @@ -149,16 +149,21 @@ public static async Task WriteJsonFileAsync( try { - using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); using var reader = new StreamReader(fileStream, Encoding.UTF8); - return await reader.ReadToEndAsync(); + return await reader.ReadToEndAsync(cancellationToken); + } + catch (OperationCanceledException) + { + logger?.LogDebug("File read was cancelled for {FilePath}", filePath); + throw; } catch (IOException ex) { logger?.LogError(ex, "IO error reading file {FilePath}", filePath); throw new ConfigurationException($"Error reading file {filePath}: {ex.Message}", ex); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) { logger?.LogError(ex, "Unexpected error reading file {FilePath}", filePath); throw new ConfigurationException($"Unexpected error reading {filePath}: {ex.Message}", ex); @@ -199,19 +204,24 @@ public static async Task WriteTextFileAsync( Directory.CreateDirectory(directory); } - using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write); + using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true); using var writer = new StreamWriter(fileStream, Encoding.UTF8); - await writer.WriteAsync(content); - await writer.FlushAsync(); + await writer.WriteAsync(content.AsMemory(), cancellationToken); + await writer.FlushAsync(cancellationToken); logger?.LogInformation("Successfully wrote to {FilePath}", filePath); } + catch (OperationCanceledException) + { + logger?.LogDebug("File write was cancelled for {FilePath}", filePath); + throw; + } catch (IOException ex) { logger?.LogError(ex, "IO error writing file {FilePath}", filePath); throw new ConfigurationException($"Error writing file {filePath}: {ex.Message}", ex); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) { logger?.LogError(ex, "Unexpected error writing file {FilePath}", filePath); throw new ConfigurationException($"Unexpected error writing {filePath}: {ex.Message}", ex); diff --git a/Shared/ConduitLLM.Core/Utilities/HttpClientHelper.cs b/Shared/ConduitLLM.Core/Utilities/HttpClientHelper.cs index 8d12ea96a..e54eabd2c 100644 --- a/Shared/ConduitLLM.Core/Utilities/HttpClientHelper.cs +++ b/Shared/ConduitLLM.Core/Utilities/HttpClientHelper.cs @@ -227,18 +227,6 @@ private static async Task ProcessResponseAsync( null); } - // Check for Anthropic authentication errors - if (errorContent.Contains("invalid bearer token", StringComparison.OrdinalIgnoreCase) || - errorContent.Contains("bearer", StringComparison.OrdinalIgnoreCase) && errorContent.Contains("anthropic", StringComparison.OrdinalIgnoreCase)) - { - logger?.LogWarning("Detected Anthropic authentication error - Bearer token used instead of x-api-key"); - throw new LLMCommunicationException( - "Anthropic authentication error: Invalid API key or authentication method. Anthropic requires 'x-api-key' header, not Bearer tokens. Please verify your API key is valid and starts with 'sk-ant-'.", - response.StatusCode, - errorContent, - null); - } - throw new LLMCommunicationException( $"API returned an error: {(int)response.StatusCode} {response.StatusCode} - {errorContent}", response.StatusCode, @@ -337,18 +325,6 @@ public static async Task SendStreamingRequestAsync? allowedDoma /// Downloads an image from a URL asynchronously. /// /// The URL of the image to download + /// The HttpClient instance to use for downloading (should be from IHttpClientFactory) + /// A token to monitor for cancellation requests /// The image data as a byte array - public static async Task DownloadImageAsync(string url) + public static async Task DownloadImageAsync(string url, HttpClient httpClient, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(url)) throw new ArgumentException("URL cannot be null or empty", nameof(url)); + if (httpClient == null) + throw new ArgumentNullException(nameof(httpClient)); + if (url.StartsWith("data:")) { byte[]? imageData = ExtractImageDataFromDataUrl(url, out _); @@ -211,12 +216,9 @@ public static async Task DownloadImageAsync(string url) return imageData; } - using var httpClient = new HttpClient(); - httpClient.Timeout = TimeSpan.FromSeconds(30); // Set a reasonable timeout - try { - return await httpClient.GetByteArrayAsync(url); + return await httpClient.GetByteArrayAsync(url, cancellationToken); } catch (HttpRequestException ex) { @@ -224,6 +226,25 @@ public static async Task DownloadImageAsync(string url) } } + /// + /// Downloads an image from a URL asynchronously. + /// + /// The URL of the image to download + /// The image data as a byte array + /// + /// This method is no longer supported. Use the overload that accepts an HttpClient from IHttpClientFactory, + /// or use IImageDownloadService to properly manage HTTP connections and avoid socket exhaustion. + /// + /// Always thrown. Use the overload that accepts an HttpClient parameter. + [Obsolete("Use the overload that accepts an HttpClient from IHttpClientFactory, or use IImageDownloadService. This method is no longer supported.", error: true)] + public static Task DownloadImageAsync(string url) + { + throw new NotSupportedException( + "This method is no longer supported due to socket exhaustion risks. " + + "Use DownloadImageAsync(url, httpClient, cancellationToken) with an HttpClient from IHttpClientFactory, " + + "or use IImageDownloadService."); + } + /// /// Determines if two byte arrays have the same contents. /// diff --git a/Shared/ConduitLLM.Core/Utilities/StreamHelper.cs b/Shared/ConduitLLM.Core/Utilities/StreamHelper.cs index b8051cba7..7f11a68d1 100644 --- a/Shared/ConduitLLM.Core/Utilities/StreamHelper.cs +++ b/Shared/ConduitLLM.Core/Utilities/StreamHelper.cs @@ -38,10 +38,84 @@ public static async IAsyncEnumerable ProcessSseStreamAsync( { var jsonOptions = options ?? DefaultJsonOptions; + await foreach (var dataBuffer in ReadSseDataLinesAsync(response, logger, cancellationToken)) + { + T? data = default; + try + { + data = JsonSerializer.Deserialize(dataBuffer, jsonOptions); + } + catch (JsonException ex) + { + logger?.LogWarning(ex, "Error deserializing stream chunk: {Data}", dataBuffer); + } + + if (data != null) + { + logger?.LogTrace("Yielding deserialized stream chunk"); + yield return data; + } + } + } + + /// + /// Extracts and deserializes data from an SSE stream. + /// + private static async Task> ExtractSseDataAsync( + HttpResponseMessage response, + ILogger? logger, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken) + { + var results = new List(); + + try + { + await foreach (var dataBuffer in ReadSseDataLinesAsync(response, logger, cancellationToken)) + { + try + { + var data = JsonSerializer.Deserialize(dataBuffer, jsonOptions); + if (data != null) + { + logger?.LogTrace("Adding deserialized stream chunk to results"); + results.Add(data); + } + } + catch (JsonException ex) + { + logger?.LogWarning(ex, "Error deserializing stream chunk: {Data}", dataBuffer); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogError(ex, "Error processing SSE stream"); + throw new LLMCommunicationException("Error processing streaming response", ex); + } + + return results; + } + + /// + /// Reads raw SSE data lines from an HTTP response stream, yielding each complete + /// event's data payload as a string. Handles SSE framing (event:/data: prefixes, + /// empty-line delimiters, and the [DONE] sentinel) so callers only receive + /// deserialization-ready JSON strings. + /// + /// The HTTP response containing the SSE stream. + /// Optional logger for debugging. + /// A token to monitor for cancellation requests. + /// An async enumerable of raw JSON data strings from SSE events. + private static async IAsyncEnumerable ReadSseDataLinesAsync( + HttpResponseMessage response, + ILogger? logger, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { logger?.LogDebug("Beginning to process SSE stream"); logger?.LogDebug("Response headers: {Headers}", response.Headers.ToString()); logger?.LogDebug("Content headers: {ContentHeaders}", response.Content.Headers.ToString()); - + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var reader = new StreamReader(stream, Encoding.UTF8); @@ -51,10 +125,10 @@ public static async IAsyncEnumerable ProcessSseStreamAsync( while (!cancellationToken.IsCancellationRequested) { - line = await reader.ReadLineAsync(); + line = await reader.ReadLineAsync(cancellationToken); if (line == null) break; // End of stream lineCount++; - + // Log first few lines for debugging if (lineCount <= 5) { @@ -73,21 +147,7 @@ public static async IAsyncEnumerable ProcessSseStreamAsync( break; } - T? data = default; - try - { - data = JsonSerializer.Deserialize(dataBuffer, jsonOptions); - } - catch (JsonException ex) - { - logger?.LogWarning(ex, "Error deserializing stream chunk: {Data}", dataBuffer); - } - - if (data != null) - { - logger?.LogTrace("Yielding deserialized stream chunk"); - yield return data; - } + yield return dataBuffer; // Reset for next event dataBuffer = string.Empty; @@ -111,99 +171,6 @@ public static async IAsyncEnumerable ProcessSseStreamAsync( } } - /// - /// Extracts and deserializes data from an SSE stream. - /// - private static async Task> ExtractSseDataAsync( - HttpResponseMessage response, - ILogger? logger, - JsonSerializerOptions jsonOptions, - CancellationToken cancellationToken) - { - var results = new List(); - - try - { - logger?.LogDebug("Beginning to process SSE stream"); - logger?.LogDebug("Response headers: {Headers}", response.Headers.ToString()); - logger?.LogDebug("Content headers: {ContentHeaders}", response.Content.Headers.ToString()); - - var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var reader = new StreamReader(stream, Encoding.UTF8); - - string? line; - string dataBuffer = string.Empty; - int lineCount = 0; - // SSE event type (only used internally for parsing, not exposed) - - while (!cancellationToken.IsCancellationRequested) - { - line = await reader.ReadLineAsync(); - if (line == null) break; // End of stream - lineCount++; - - // Log first few lines for debugging - if (lineCount <= 5) - { - logger?.LogDebug("SSE line {LineNumber}: '{Line}'", lineCount, line); - } - - if (string.IsNullOrEmpty(line)) - { - // Empty line indicates the end of an event - if (!string.IsNullOrEmpty(dataBuffer)) - { - // Process the complete event data - if (dataBuffer == "[DONE]") - { - logger?.LogDebug("Received end of stream marker [DONE]"); - break; - } - - try - { - var data = JsonSerializer.Deserialize(dataBuffer, jsonOptions); - if (data != null) - { - logger?.LogTrace("Adding deserialized stream chunk to results"); - results.Add(data); - } - } - catch (JsonException ex) - { - logger?.LogWarning(ex, "Error deserializing stream chunk: {Data}", dataBuffer); - } - - // Reset for next event - dataBuffer = string.Empty; - } - continue; - } - - // Check for event type - if (line.StartsWith("event:")) - { - // Event line - just continue to the next line - continue; - } - - // Process data lines - if (line.StartsWith("data:")) - { - var data = line.Substring(5).TrimStart(); - dataBuffer = data; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger?.LogError(ex, "Error processing SSE stream"); - throw new LLMCommunicationException("Error processing streaming response", ex); - } - - return results; - } - /// /// Processes a server-sent event (SSE) stream specially formatted for LLM chat completion responses. /// @@ -363,7 +330,7 @@ private static async Task> ExtractCustomStreamDataAsync> ExtractCustomStreamDataAsyncThrown if the collection is null or empty. public static void RequireNonEmpty(IEnumerable? collection, string parameterName) { - if (collection == null || collection.Count() == 0) + if (collection == null || !collection.Any()) { throw new ValidationException($"{parameterName} collection cannot be null or empty"); } diff --git a/Shared/ConduitLLM.Functions/ConduitLLM.Functions.csproj b/Shared/ConduitLLM.Functions/ConduitLLM.Functions.csproj index f02d7a087..f774070b9 100644 --- a/Shared/ConduitLLM.Functions/ConduitLLM.Functions.csproj +++ b/Shared/ConduitLLM.Functions/ConduitLLM.Functions.csproj @@ -4,22 +4,29 @@ net10.0 enable enable + + $(NoWarn);NU1510 - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + + + + + - + - + diff --git a/Shared/ConduitLLM.Functions/Entities/FunctionCallAudit.cs b/Shared/ConduitLLM.Functions/Entities/FunctionCallAudit.cs index 84cadc8bc..8dbe15c01 100644 --- a/Shared/ConduitLLM.Functions/Entities/FunctionCallAudit.cs +++ b/Shared/ConduitLLM.Functions/Entities/FunctionCallAudit.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using ConduitLLM.Functions.Enums; +using ConduitLLM.Functions.Interfaces; namespace ConduitLLM.Functions.Entities; @@ -10,7 +11,7 @@ namespace ConduitLLM.Functions.Entities; /// Links to both the parent chat completion request and the function execution. /// [Table("FunctionCallAudits")] -public class FunctionCallAudit +public class FunctionCallAudit : IAuditEvent { /// /// Unique identifier for this audit event diff --git a/Shared/ConduitLLM.Functions/Entities/FunctionConfiguration.cs b/Shared/ConduitLLM.Functions/Entities/FunctionConfiguration.cs index a59c4975f..6aa30407f 100644 --- a/Shared/ConduitLLM.Functions/Entities/FunctionConfiguration.cs +++ b/Shared/ConduitLLM.Functions/Entities/FunctionConfiguration.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using ConduitLLM.Functions.Entities.Interfaces; using ConduitLLM.Functions.Enums; namespace ConduitLLM.Functions.Entities; @@ -11,7 +12,7 @@ namespace ConduitLLM.Functions.Entities; /// The Id is the canonical identifier (not ProviderType). /// [Table("FunctionConfigurations")] -public class FunctionConfiguration +public class FunctionConfiguration : IIdentifiableEntity { /// /// Unique identifier for this function configuration (canonical identifier) diff --git a/Shared/ConduitLLM.Functions/Entities/FunctionCost.cs b/Shared/ConduitLLM.Functions/Entities/FunctionCost.cs index 0e400eba4..800178a43 100644 --- a/Shared/ConduitLLM.Functions/Entities/FunctionCost.cs +++ b/Shared/ConduitLLM.Functions/Entities/FunctionCost.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Functions.Entities.Interfaces; using ConduitLLM.Functions.Enums; namespace ConduitLLM.Functions.Entities; @@ -9,7 +10,7 @@ namespace ConduitLLM.Functions.Entities; /// Supports multiple pricing models via the Strategy pattern. /// [Table("FunctionCosts")] -public class FunctionCost +public class FunctionCost : IIdentifiableEntity { /// /// Unique identifier for this cost configuration diff --git a/Shared/ConduitLLM.Functions/Entities/FunctionCostMapping.cs b/Shared/ConduitLLM.Functions/Entities/FunctionCostMapping.cs index dd9bd2fc9..721808b89 100644 --- a/Shared/ConduitLLM.Functions/Entities/FunctionCostMapping.cs +++ b/Shared/ConduitLLM.Functions/Entities/FunctionCostMapping.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Functions.Entities.Interfaces; namespace ConduitLLM.Functions.Entities; @@ -8,7 +9,7 @@ namespace ConduitLLM.Functions.Entities; /// Allows different functions to share cost configs or have function-specific pricing. /// [Table("FunctionCostMappings")] -public class FunctionCostMapping +public class FunctionCostMapping : IIdentifiableEntity { /// /// Unique identifier for this mapping diff --git a/Shared/ConduitLLM.Functions/Entities/FunctionCredential.cs b/Shared/ConduitLLM.Functions/Entities/FunctionCredential.cs index d8527a84b..aa611730c 100644 --- a/Shared/ConduitLLM.Functions/Entities/FunctionCredential.cs +++ b/Shared/ConduitLLM.Functions/Entities/FunctionCredential.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using ConduitLLM.Functions.Entities.Interfaces; using ConduitLLM.Functions.Enums; namespace ConduitLLM.Functions.Entities; @@ -11,7 +12,7 @@ namespace ConduitLLM.Functions.Entities; /// Credentials are shared across all function configurations of the same provider type. /// [Table("FunctionCredentials")] -public class FunctionCredential +public class FunctionCredential : ICredentialEntity, IIdentifiableEntity { /// /// Unique identifier for this credential diff --git a/Shared/ConduitLLM.Functions/Entities/FunctionExecution.cs b/Shared/ConduitLLM.Functions/Entities/FunctionExecution.cs index 99f4d0398..56e7da783 100644 --- a/Shared/ConduitLLM.Functions/Entities/FunctionExecution.cs +++ b/Shared/ConduitLLM.Functions/Entities/FunctionExecution.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ConduitLLM.Functions.Entities.Interfaces; using ConduitLLM.Functions.Enums; namespace ConduitLLM.Functions.Entities; @@ -9,7 +10,7 @@ namespace ConduitLLM.Functions.Entities; /// Tracks the complete lifecycle from request to completion/failure. /// [Table("FunctionExecutions")] -public class FunctionExecution +public class FunctionExecution : IIdentifiableEntity { /// /// Unique identifier for this execution diff --git a/Shared/ConduitLLM.Functions/Entities/Interfaces/ICredentialEntity.cs b/Shared/ConduitLLM.Functions/Entities/Interfaces/ICredentialEntity.cs new file mode 100644 index 000000000..82f248548 --- /dev/null +++ b/Shared/ConduitLLM.Functions/Entities/Interfaces/ICredentialEntity.cs @@ -0,0 +1,12 @@ +namespace ConduitLLM.Functions.Entities.Interfaces; + +/// +/// Shared interface for credential entities that support enable/disable and primary selection. +/// Implemented by both ProviderKeyCredential and FunctionCredential. +/// +public interface ICredentialEntity +{ + int Id { get; set; } + bool IsEnabled { get; set; } + bool IsPrimary { get; set; } +} diff --git a/Shared/ConduitLLM.Functions/Entities/Interfaces/IIdentifiableEntity.cs b/Shared/ConduitLLM.Functions/Entities/Interfaces/IIdentifiableEntity.cs new file mode 100644 index 000000000..3f5e9beef --- /dev/null +++ b/Shared/ConduitLLM.Functions/Entities/Interfaces/IIdentifiableEntity.cs @@ -0,0 +1,15 @@ +namespace ConduitLLM.Functions.Entities.Interfaces; + +/// +/// Base marker interface for entities with a typed primary key. +/// Defined in the Functions project to allow shared use across projects +/// without circular dependencies. +/// +/// The type of the primary key (e.g., int, Guid) +public interface IIdentifiableEntity where TKey : IEquatable +{ + /// + /// Gets or sets the unique identifier for this entity. + /// + TKey Id { get; set; } +} diff --git a/Shared/ConduitLLM.Functions/Interfaces/IAuditEvent.cs b/Shared/ConduitLLM.Functions/Interfaces/IAuditEvent.cs new file mode 100644 index 000000000..a7827c580 --- /dev/null +++ b/Shared/ConduitLLM.Functions/Interfaces/IAuditEvent.cs @@ -0,0 +1,13 @@ +namespace ConduitLLM.Functions.Interfaces; + +/// +/// Interface for audit event entities that have a timestamp for retention cleanup. +/// +public interface IAuditEvent +{ + /// + /// The timestamp when the audit event occurred. + /// Used for data retention cleanup. + /// + DateTime Timestamp { get; set; } +} diff --git a/Shared/ConduitLLM.Functions/Interfaces/IFunctionClientFactory.cs b/Shared/ConduitLLM.Functions/Interfaces/IFunctionClientFactory.cs index 2e244a6ff..da7c21df4 100644 --- a/Shared/ConduitLLM.Functions/Interfaces/IFunctionClientFactory.cs +++ b/Shared/ConduitLLM.Functions/Interfaces/IFunctionClientFactory.cs @@ -8,11 +8,11 @@ namespace ConduitLLM.Functions.Interfaces; public interface IFunctionClientFactory { /// - /// Gets a function client for the specified provider type. + /// Gets a function client for the specified provider type asynchronously. /// /// The function provider type. /// The function configuration ID. /// A function client instance. /// Thrown when configuration is invalid or provider is unsupported. - IFunctionClient GetClient(FunctionProviderType providerType, int functionConfigurationId); + Task GetClientAsync(FunctionProviderType providerType, int functionConfigurationId); } diff --git a/Shared/ConduitLLM.Functions/Interfaces/IFunctionConfigurationRepository.cs b/Shared/ConduitLLM.Functions/Interfaces/IFunctionConfigurationRepository.cs index 0eca41b35..7ea57d495 100644 --- a/Shared/ConduitLLM.Functions/Interfaces/IFunctionConfigurationRepository.cs +++ b/Shared/ConduitLLM.Functions/Interfaces/IFunctionConfigurationRepository.cs @@ -37,8 +37,33 @@ public interface IFunctionConfigurationRepository /// /// Cancellation token /// A list of all function configurations + /// + /// DEPRECATED: Use GetAllUnboundedAsync() for unbounded queries, + /// or GetPaginatedAsync() for bounded pagination. + /// + [Obsolete("Use GetAllUnboundedAsync() for cache warming/exports, or GetPaginatedAsync() for bounded queries. This method will be removed in a future version.")] Task> GetAllAsync(CancellationToken cancellationToken = default); + /// + /// Gets all function configurations WITHOUT pagination. Use ONLY for legitimate batch operations + /// like cache warming, exports, or migrations. + /// + /// Cancellation token + /// A list of all function configurations + Task> GetAllUnboundedAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a paginated list of function configurations. + /// + /// Page number (1-based) + /// Number of items per page + /// Cancellation token + /// A tuple containing the items and total count + Task<(List Items, int TotalCount)> GetPaginatedAsync( + int page, + int pageSize, + CancellationToken cancellationToken = default); + /// /// Gets all enabled function configurations /// @@ -75,14 +100,16 @@ public interface IFunctionConfigurationRepository /// /// The function configuration to update /// Cancellation token - Task UpdateAsync(FunctionConfiguration functionConfiguration, CancellationToken cancellationToken = default); + /// True if the entity was updated + Task UpdateAsync(FunctionConfiguration functionConfiguration, CancellationToken cancellationToken = default); /// /// Deletes a function configuration by ID /// /// The function configuration ID /// Cancellation token - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// True if the entity was deleted + Task DeleteAsync(int id, CancellationToken cancellationToken = default); /// /// Checks if a function configuration name already exists diff --git a/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostMappingRepository.cs b/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostMappingRepository.cs index 06125e1c5..bb2dd3f34 100644 --- a/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostMappingRepository.cs +++ b/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostMappingRepository.cs @@ -44,14 +44,16 @@ public interface IFunctionCostMappingRepository /// /// The mapping to update /// Cancellation token - Task UpdateAsync(FunctionCostMapping mapping, CancellationToken cancellationToken = default); + /// True if the entity was updated + Task UpdateAsync(FunctionCostMapping mapping, CancellationToken cancellationToken = default); /// /// Deletes a cost mapping by ID /// /// The mapping ID /// Cancellation token - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// True if the entity was deleted + Task DeleteAsync(int id, CancellationToken cancellationToken = default); /// /// Deactivates all cost mappings for a function configuration diff --git a/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostRepository.cs b/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostRepository.cs index 4d0b8c8e3..4ccdeed1b 100644 --- a/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostRepository.cs +++ b/Shared/ConduitLLM.Functions/Interfaces/IFunctionCostRepository.cs @@ -59,12 +59,14 @@ public interface IFunctionCostRepository /// /// The cost to update /// Cancellation token - Task UpdateAsync(FunctionCost functionCost, CancellationToken cancellationToken = default); + /// True if the entity was updated + Task UpdateAsync(FunctionCost functionCost, CancellationToken cancellationToken = default); /// /// Deletes a function cost by ID /// /// The cost ID /// Cancellation token - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// True if the entity was deleted + Task DeleteAsync(int id, CancellationToken cancellationToken = default); } diff --git a/Shared/ConduitLLM.Functions/Interfaces/IFunctionCredentialRepository.cs b/Shared/ConduitLLM.Functions/Interfaces/IFunctionCredentialRepository.cs index c6e504814..9a5c0c50c 100644 --- a/Shared/ConduitLLM.Functions/Interfaces/IFunctionCredentialRepository.cs +++ b/Shared/ConduitLLM.Functions/Interfaces/IFunctionCredentialRepository.cs @@ -13,8 +13,33 @@ public interface IFunctionCredentialRepository /// /// Cancellation token /// List of all credentials + /// + /// DEPRECATED: Use GetAllUnboundedAsync() for unbounded queries, + /// or GetPaginatedAsync() for bounded pagination. + /// + [Obsolete("Use GetAllUnboundedAsync() for cache warming/exports, or GetPaginatedAsync() for bounded queries. This method will be removed in a future version.")] Task> GetAllAsync(CancellationToken cancellationToken = default); + /// + /// Gets all function credentials WITHOUT pagination. Use ONLY for legitimate batch operations + /// like cache warming, exports, or migrations. + /// + /// Cancellation token + /// List of all credentials + Task> GetAllUnboundedAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a paginated list of function credentials. + /// + /// Page number (1-based) + /// Number of items per page + /// Cancellation token + /// A tuple containing the items and total count + Task<(List Items, int TotalCount)> GetPaginatedAsync( + int page, + int pageSize, + CancellationToken cancellationToken = default); + /// /// Gets a function credential by ID /// @@ -69,14 +94,16 @@ public interface IFunctionCredentialRepository /// /// The credential to update /// Cancellation token - Task UpdateAsync(FunctionCredential credential, CancellationToken cancellationToken = default); + /// True if the entity was updated + Task UpdateAsync(FunctionCredential credential, CancellationToken cancellationToken = default); /// /// Deletes a function credential by ID /// /// The credential ID /// Cancellation token - Task DeleteAsync(int id, CancellationToken cancellationToken = default); + /// True if the entity was deleted + Task DeleteAsync(int id, CancellationToken cancellationToken = default); /// /// Sets a credential as primary and unsets any existing primary credential for the provider type diff --git a/Shared/ConduitLLM.Functions/Interfaces/IFunctionExecutionRepository.cs b/Shared/ConduitLLM.Functions/Interfaces/IFunctionExecutionRepository.cs index d1d68111d..a38477598 100644 --- a/Shared/ConduitLLM.Functions/Interfaces/IFunctionExecutionRepository.cs +++ b/Shared/ConduitLLM.Functions/Interfaces/IFunctionExecutionRepository.cs @@ -4,10 +4,13 @@ namespace ConduitLLM.Functions.Interfaces; /// -/// Repository interface for managing function executions +/// Repository interface for managing function executions. +/// Provides standard CRUD operations plus domain-specific methods for execution management. /// public interface IFunctionExecutionRepository { + #region Standard CRUD Operations + /// /// Gets a function execution by ID /// @@ -16,6 +19,45 @@ public interface IFunctionExecutionRepository /// The function execution or null if not found Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + /// + /// Gets a paginated list of function executions. + /// + /// Page number (1-based) + /// Number of items per page + /// Cancellation token + /// A tuple containing the items and total count + Task<(List Items, int TotalCount)> GetPaginatedAsync( + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// Checks if a function execution with the given ID exists. + /// + /// The execution ID + /// Cancellation token + /// True if the execution exists, false otherwise + Task ExistsAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Gets the total count of function executions. + /// + /// Cancellation token + /// The total count of executions + Task CountAsync(CancellationToken cancellationToken = default); + + /// + /// Deletes a function execution by ID. + /// + /// The execution ID + /// Cancellation token + /// True if deleted, false if not found + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + #endregion + + #region Query Methods + /// /// Gets all executions for a specific virtual key /// @@ -64,6 +106,10 @@ public interface IFunctionExecutionRepository /// List of executions ready for retry Task> GetReadyForRetryAsync(CancellationToken cancellationToken = default); + #endregion + + #region Create/Update Operations + /// /// Creates a new function execution /// @@ -106,4 +152,6 @@ public interface IFunctionExecutionRepository /// Cancellation token /// Number of executions deleted Task DeleteOldExecutionsAsync(DateTime olderThan, CancellationToken cancellationToken = default); + + #endregion } diff --git a/Shared/ConduitLLM.Functions/Providers/Exa/ExaClient.cs b/Shared/ConduitLLM.Functions/Providers/Exa/ExaClient.cs index 389833dc8..ff12835e0 100644 --- a/Shared/ConduitLLM.Functions/Providers/Exa/ExaClient.cs +++ b/Shared/ConduitLLM.Functions/Providers/Exa/ExaClient.cs @@ -92,19 +92,18 @@ private string DetermineBaseUrl() /// /// Creates an HTTP client instance. /// + /// Thrown when IHttpClientFactory is not available. protected virtual HttpClient CreateHttpClient(string? apiKey = null) { - HttpClient client; - - if (_httpClientFactory != null) - { - client = _httpClientFactory.CreateClient($"{ProviderName}FunctionClient"); - } - else + if (_httpClientFactory == null) { - client = new HttpClient(); + throw new InvalidOperationException( + $"IHttpClientFactory is required for {ProviderName} but was not injected. " + + "Ensure IHttpClientFactory is registered in the dependency injection container. " + + "Creating HttpClient instances directly can cause socket exhaustion under load."); } + var client = _httpClientFactory.CreateClient($"{ProviderName}FunctionClient"); ConfigureHttpClient(client, apiKey); return client; } diff --git a/Shared/ConduitLLM.Functions/Providers/Tavily/TavilyClient.cs b/Shared/ConduitLLM.Functions/Providers/Tavily/TavilyClient.cs index b960afd35..532cb79b1 100644 --- a/Shared/ConduitLLM.Functions/Providers/Tavily/TavilyClient.cs +++ b/Shared/ConduitLLM.Functions/Providers/Tavily/TavilyClient.cs @@ -93,19 +93,18 @@ private string DetermineBaseUrl() /// /// Creates an HTTP client instance. /// + /// Thrown when IHttpClientFactory is not available. protected virtual HttpClient CreateHttpClient(string? apiKey = null) { - HttpClient client; - - if (_httpClientFactory != null) - { - client = _httpClientFactory.CreateClient($"{ProviderName}FunctionClient"); - } - else + if (_httpClientFactory == null) { - client = new HttpClient(); + throw new InvalidOperationException( + $"IHttpClientFactory is required for {ProviderName} but was not injected. " + + "Ensure IHttpClientFactory is registered in the dependency injection container. " + + "Creating HttpClient instances directly can cause socket exhaustion under load."); } + var client = _httpClientFactory.CreateClient($"{ProviderName}FunctionClient"); ConfigureHttpClient(client, apiKey); return client; } diff --git a/Shared/ConduitLLM.Functions/Services/FunctionClientFactory.cs b/Shared/ConduitLLM.Functions/Services/FunctionClientFactory.cs index 5519db457..87a86a095 100644 --- a/Shared/ConduitLLM.Functions/Services/FunctionClientFactory.cs +++ b/Shared/ConduitLLM.Functions/Services/FunctionClientFactory.cs @@ -32,19 +32,24 @@ public FunctionClientFactory( _httpClientFactory = httpClientFactory; } - /// - public IFunctionClient GetClient(FunctionProviderType providerType, int functionConfigurationId) + /// + /// Gets a function client asynchronously. + /// + /// The type of function provider. + /// The function configuration ID. + /// The function client for the specified provider. + /// Thrown when configuration or credentials are not found. + /// Thrown when the provider type is not supported. + public async Task GetClientAsync(FunctionProviderType providerType, int functionConfigurationId) { - // Load configuration synchronously (already loaded in ExecuteAsync, this is just for client creation) - var configuration = _configurationRepository.GetByIdAsync(functionConfigurationId).GetAwaiter().GetResult(); + var configuration = await _configurationRepository.GetByIdAsync(functionConfigurationId); if (configuration == null) { throw new InvalidOperationException($"Function configuration {functionConfigurationId} not found"); } // Get credentials for this provider type - var credentials = _credentialRepository.GetByProviderTypeAsync(configuration.ProviderType) - .GetAwaiter().GetResult(); + var credentials = await _credentialRepository.GetByProviderTypeAsync(configuration.ProviderType); var credential = credentials.FirstOrDefault(c => c.IsEnabled); if (credential == null) diff --git a/Shared/ConduitLLM.Functions/Services/FunctionExecutionService.cs b/Shared/ConduitLLM.Functions/Services/FunctionExecutionService.cs index 7e6e96d11..9b44db28e 100644 --- a/Shared/ConduitLLM.Functions/Services/FunctionExecutionService.cs +++ b/Shared/ConduitLLM.Functions/Services/FunctionExecutionService.cs @@ -138,7 +138,7 @@ public async Task ExecuteAsync( } // 5. Execute function via provider client - var client = _clientFactory.GetClient(configuration.ProviderType, functionConfigurationId); + var client = await _clientFactory.GetClientAsync(configuration.ProviderType, functionConfigurationId); _logger.LogInformation("Executing {ProviderType} function via client...", configuration.ProviderType); diff --git a/Shared/ConduitLLM.Providers/Authentication/ApiKeyHeaderStrategy.cs b/Shared/ConduitLLM.Providers/Authentication/ApiKeyHeaderStrategy.cs new file mode 100644 index 000000000..93869413c --- /dev/null +++ b/Shared/ConduitLLM.Providers/Authentication/ApiKeyHeaderStrategy.cs @@ -0,0 +1,77 @@ +using System.Net.Http.Headers; + +namespace ConduitLLM.Providers.Authentication +{ + /// + /// Authentication strategy using a custom API key header. + /// + /// + /// Used by Azure OpenAI and other providers that use custom header-based authentication. + /// + /// Default header format: api-key: {apiKey} + /// + public sealed class ApiKeyHeaderStrategy : IAuthenticationStrategy + { + /// + /// Singleton instance using the default "api-key" header name (for Azure OpenAI). + /// + public static readonly ApiKeyHeaderStrategy AzureInstance = new("api-key"); + + private readonly string _headerName; + + /// + /// Creates a new ApiKeyHeaderStrategy with the specified header name. + /// + /// The header name to use for the API key. + public ApiKeyHeaderStrategy(string headerName) + { + if (string.IsNullOrWhiteSpace(headerName)) + { + throw new ArgumentException("Header name cannot be null or empty", nameof(headerName)); + } + + _headerName = headerName; + } + + /// + public string AuthenticationType => $"Header:{_headerName}"; + + /// + public void ApplyAuthentication(HttpClient client, string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + // Remove existing header if present + client.DefaultRequestHeaders.Remove(_headerName); + client.DefaultRequestHeaders.Add(_headerName, apiKey); + } + + /// + public void ApplyAuthentication(HttpRequestMessage request, string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + // Remove existing header if present + request.Headers.Remove(_headerName); + request.Headers.Add(_headerName, apiKey); + } + + /// + public AuthenticationHeaderValue? CreateAuthenticationHeader(string apiKey) + { + // This strategy doesn't use the Authorization header + return null; + } + + /// + /// Gets the header name used by this strategy. + /// + public string HeaderName => _headerName; + } +} diff --git a/Shared/ConduitLLM.Providers/Authentication/BearerTokenStrategy.cs b/Shared/ConduitLLM.Providers/Authentication/BearerTokenStrategy.cs new file mode 100644 index 000000000..d436c5545 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Authentication/BearerTokenStrategy.cs @@ -0,0 +1,63 @@ +using System.Net.Http.Headers; + +namespace ConduitLLM.Providers.Authentication +{ + /// + /// Authentication strategy using Bearer token in the Authorization header. + /// + /// + /// Used by most OpenAI-compatible providers including: + /// - OpenAI + /// - Groq + /// - Fireworks + /// - Cerebras + /// - SambaNova + /// - DeepInfra + /// - MiniMax + /// + /// Header format: Authorization: Bearer {apiKey} + /// + public sealed class BearerTokenStrategy : IAuthenticationStrategy + { + /// + /// Singleton instance for reuse. + /// + public static readonly BearerTokenStrategy Instance = new(); + + /// + public string AuthenticationType => "Bearer"; + + /// + public void ApplyAuthentication(HttpClient client, string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + + /// + public void ApplyAuthentication(HttpRequestMessage request, string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + + /// + public AuthenticationHeaderValue? CreateAuthenticationHeader(string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + return new AuthenticationHeaderValue("Bearer", apiKey); + } + } +} diff --git a/Shared/ConduitLLM.Providers/Authentication/IAuthenticationStrategy.cs b/Shared/ConduitLLM.Providers/Authentication/IAuthenticationStrategy.cs new file mode 100644 index 000000000..8a4f73c6d --- /dev/null +++ b/Shared/ConduitLLM.Providers/Authentication/IAuthenticationStrategy.cs @@ -0,0 +1,46 @@ +using System.Net.Http.Headers; + +namespace ConduitLLM.Providers.Authentication +{ + /// + /// Defines the contract for provider authentication strategies. + /// + /// + /// Different LLM providers use different authentication methods: + /// - Bearer token (OpenAI, Groq, Fireworks, etc.) + /// - Token scheme (Replicate) + /// - API key header (Azure OpenAI) + /// + /// This interface allows providers to specify their authentication method + /// without duplicating authentication logic across provider implementations. + /// + public interface IAuthenticationStrategy + { + /// + /// Gets the authentication type name for logging and diagnostics. + /// + string AuthenticationType { get; } + + /// + /// Applies authentication to an HttpClient's default request headers. + /// + /// The HttpClient to configure. + /// The API key to use for authentication. + void ApplyAuthentication(HttpClient client, string apiKey); + + /// + /// Applies authentication to an individual HttpRequestMessage. + /// + /// The request to configure. + /// The API key to use for authentication. + void ApplyAuthentication(HttpRequestMessage request, string apiKey); + + /// + /// Creates an AuthenticationHeaderValue for the given API key. + /// Returns null if this strategy uses a different header mechanism. + /// + /// The API key to use. + /// An AuthenticationHeaderValue or null if not applicable. + AuthenticationHeaderValue? CreateAuthenticationHeader(string apiKey); + } +} diff --git a/Shared/ConduitLLM.Providers/Authentication/TokenStrategy.cs b/Shared/ConduitLLM.Providers/Authentication/TokenStrategy.cs new file mode 100644 index 000000000..349bb59d0 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Authentication/TokenStrategy.cs @@ -0,0 +1,56 @@ +using System.Net.Http.Headers; + +namespace ConduitLLM.Providers.Authentication +{ + /// + /// Authentication strategy using Token scheme in the Authorization header. + /// + /// + /// Used by Replicate API. + /// + /// Header format: Authorization: Token {apiKey} + /// + public sealed class TokenStrategy : IAuthenticationStrategy + { + /// + /// Singleton instance for reuse. + /// + public static readonly TokenStrategy Instance = new(); + + /// + public string AuthenticationType => "Token"; + + /// + public void ApplyAuthentication(HttpClient client, string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", apiKey); + } + + /// + public void ApplyAuthentication(HttpRequestMessage request, string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Token", apiKey); + } + + /// + public AuthenticationHeaderValue? CreateAuthenticationHeader(string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("API key cannot be null or empty", nameof(apiKey)); + } + + return new AuthenticationHeaderValue("Token", apiKey); + } + } +} diff --git a/Shared/ConduitLLM.Providers/BaseLLMClient.cs b/Shared/ConduitLLM.Providers/BaseLLMClient.cs index 67613d64b..584c24104 100644 --- a/Shared/ConduitLLM.Providers/BaseLLMClient.cs +++ b/Shared/ConduitLLM.Providers/BaseLLMClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Text.Json; @@ -6,20 +7,53 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Metrics; using ConduitLLM.Core.Models; using ConduitLLM.Core.Utilities; +using ConduitLLM.Providers.Authentication; using ConduitLLM.Providers.Common.Models; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; namespace ConduitLLM.Providers { /// - /// Base class for LLM client implementations that provides common functionality + /// Base class for LLM client implementations that provides common functionality /// and standardized handling of requests, responses, and errors. /// public abstract class BaseLLMClient : ILLMClient, IAuthenticationVerifiable { + /// + /// Default timeout for standard API requests (2 minutes). + /// Matches . + /// + protected static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(120); + + /// + /// Timeout for authentication verification requests (30 seconds). + /// Matches . + /// + protected static readonly TimeSpan AuthVerificationTimeout = TimeSpan.FromSeconds(30); + + /// + /// Timeout for image generation requests (3 minutes). + /// Matches . + /// + protected static readonly TimeSpan ImageGenerationTimeout = TimeSpan.FromSeconds(180); + + /// + /// Timeout for video generation requests (10 minutes). + /// Matches . + /// + protected static readonly TimeSpan VideoGenerationTimeout = TimeSpan.FromMinutes(10); + + /// + /// Timeout for large file downloads (30 minutes). + /// Matches . + /// + protected static readonly TimeSpan LargeFileDownloadTimeout = TimeSpan.FromMinutes(30); + protected readonly Provider Provider; protected readonly ProviderKeyCredential PrimaryKeyCredential; protected readonly string ProviderModelId; @@ -34,6 +68,24 @@ public abstract class BaseLLMClient : ILLMClient, IAuthenticationVerifiable DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; + /// + /// Gets the authentication strategy for this provider. + /// Override in derived classes to use provider-specific authentication methods. + /// + /// + /// Default is Bearer token authentication. Override this property in derived classes + /// for providers that use different authentication methods (e.g., Token, api-key header). + /// + protected virtual IAuthenticationStrategy AuthenticationStrategy => BearerTokenStrategy.Instance; + + /// + /// Gets the provider configuration from the registry. + /// Returns null if no configuration is registered for this provider type. + /// + protected virtual ProviderConfiguration? ProviderConfig => + ProviderConfigurationRegistry.TryGetConfiguration(Provider.ProviderType, out var config) + ? config : null; + /// /// Initializes a new instance of the class. /// @@ -81,19 +133,19 @@ protected virtual void ValidateCredentials() /// /// Optional API key to override the one in credentials. /// A configured HttpClient instance. + /// Thrown when IHttpClientFactory is not available. protected virtual HttpClient CreateHttpClient(string? apiKey = null) { - HttpClient client; - - if (HttpClientFactory != null) - { - client = HttpClientFactory.CreateClient($"{ProviderName}LLMClient"); - } - else + if (HttpClientFactory == null) { - client = new HttpClient(); + throw new InvalidOperationException( + $"IHttpClientFactory is required for {ProviderName} but was not injected. " + + "Ensure IHttpClientFactory is registered in the dependency injection container. " + + "Creating HttpClient instances directly can cause socket exhaustion under load."); } + var client = HttpClientFactory.CreateClient($"{ProviderName}LLMClient"); + string effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey!; if (string.IsNullOrWhiteSpace(effectiveApiKey)) { @@ -118,21 +170,21 @@ protected virtual void ConfigureHttpClient(HttpClient client, string apiKey) // Configure authentication ConfigureAuthentication(client, apiKey); - - // Configure timeout - client.Timeout = TimeSpan.FromMinutes(5); + + // Configure default timeout (can be overridden per-request) + client.Timeout = DefaultRequestTimeout; } /// /// Configures authentication for the HttpClient. - /// Override in derived classes to use provider-specific authentication methods. + /// Uses the property to determine the authentication method. + /// Override the property in derived classes to change the authentication method. /// /// The HttpClient to configure. /// The API key to use for authentication. protected virtual void ConfigureAuthentication(HttpClient client, string apiKey) { - // Default Bearer token authentication - can be overridden by providers - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + AuthenticationStrategy.ApplyAuthentication(client, apiKey); } /// @@ -141,29 +193,30 @@ protected virtual void ConfigureAuthentication(HttpClient client, string apiKey) /// /// The API key to use for authentication. /// A configured HttpClient for authentication verification. + /// Thrown when IHttpClientFactory is not available. protected virtual HttpClient CreateAuthenticationVerificationClient(string apiKey) { - HttpClient client; - if (HttpClientFactory != null) + if (HttpClientFactory == null) { - client = HttpClientFactory.CreateClient($"{ProviderName}AuthVerification"); - } - else - { - client = new HttpClient(); + throw new InvalidOperationException( + $"IHttpClientFactory is required for {ProviderName} authentication verification but was not injected. " + + "Ensure IHttpClientFactory is registered in the dependency injection container. " + + "Creating HttpClient instances directly can cause socket exhaustion under load."); } + var client = HttpClientFactory.CreateClient($"{ProviderName}AuthVerification"); + // Configure basic headers client.DefaultRequestHeaders.Accept.Clear(); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); client.DefaultRequestHeaders.Add("User-Agent", "ConduitLLM"); - + // Configure authentication ConfigureAuthentication(client, apiKey); - + // Use a shorter timeout for health checks - client.Timeout = TimeSpan.FromSeconds(30); - + client.Timeout = AuthVerificationTimeout; + // Do NOT set BaseAddress - we'll be using absolute URLs return client; } @@ -278,7 +331,8 @@ protected async Task ReadErrorContentAsync(HttpResponseMessage response, } /// - /// Safely executes an API request with standardized error handling. + /// Safely executes an API request with standardized error handling, tracing, + /// metrics, and structured logging scope. /// /// The type of result expected from the operation. /// The operation to execute. @@ -290,14 +344,133 @@ protected async Task ExecuteApiRequestAsync( string operationName, CancellationToken cancellationToken) { - return await ExceptionHandler.HandleHttpRequestAsync( - async () => - { - cancellationToken.ThrowIfCancellationRequested(); - return await operation(); - }, - Logger, - $"{ProviderName} ({operationName})"); + using var activity = ProviderInstrumentation.StartRequestActivity( + operationName, ProviderName, ProviderTypeName, ProviderModelId); + using var scope = BeginProviderLogScope(operationName); + var stopwatch = Stopwatch.StartNew(); + + try + { + var result = await ExceptionHandler.HandleHttpRequestAsync( + async () => + { + cancellationToken.ThrowIfCancellationRequested(); + return await operation(); + }, + Logger, + $"{ProviderName} ({operationName})"); + + stopwatch.Stop(); + ProviderInstrumentation.RecordRequest( + operationName, ProviderName, ProviderTypeName, ProviderModelId, + stopwatch.Elapsed.TotalMilliseconds, success: true); + activity?.SetStatus(ActivityStatusCode.Ok); + return result; + } + catch (OperationCanceledException) + { + stopwatch.Stop(); + ProviderInstrumentation.RecordRequest( + operationName, ProviderName, ProviderTypeName, ProviderModelId, + stopwatch.Elapsed.TotalMilliseconds, success: false, + errorType: nameof(OperationCanceledException)); + activity?.SetStatus(ActivityStatusCode.Error, "cancelled"); + throw; + } + catch (Exception ex) + { + stopwatch.Stop(); + ProviderInstrumentation.RecordRequest( + operationName, ProviderName, ProviderTypeName, ProviderModelId, + stopwatch.Elapsed.TotalMilliseconds, success: false, + errorType: ex.GetType().Name); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } + + /// + /// Provider type name (e.g., "OpenAI", "Groq") used as a tag on spans and metrics. + /// + protected string ProviderTypeName => Provider.ProviderType.ToString(); + + /// + /// Opens an scope populated with provider, model, key, and + /// operation context so that all downstream log entries inherit correlation tags. + /// + /// The current operation name (e.g., "ChatCompletion"). + /// A disposable scope; may be null if the underlying logger does not support scopes. + protected IDisposable? BeginProviderLogScope(string operationName) + { + return Logger.BeginScope(new Dictionary + { + ["ProviderName"] = ProviderName, + ["ProviderType"] = ProviderTypeName, + ["ProviderId"] = Provider.Id, + ["KeyCredentialId"] = PrimaryKeyCredential.Id, + ["Model"] = ProviderModelId, + ["Operation"] = operationName + }); + } + + /// + /// Records token usage metrics + tags reported by the provider. + /// Safe to call with a null ; counters are only incremented + /// for token dimensions that are populated. + /// + /// The usage object returned by the provider, or null. + /// The operation that produced the usage (e.g., "ChatCompletion"). + protected void RecordUsage(Usage? usage, string operationName) + { + if (usage == null) + { + return; + } + + ProviderInstrumentation.RecordUsage( + operationName, + ProviderName, + ProviderTypeName, + ProviderModelId, + usage.PromptTokens, + usage.CompletionTokens, + usage.TotalTokens); + + if ((usage.PromptTokens ?? 0) > 0 || + (usage.CompletionTokens ?? 0) > 0 || + (usage.TotalTokens ?? 0) > 0) + { + Logger.LogDebug( + "{Provider} {Operation} usage: Prompt={Prompt}, Completion={Completion}, Total={Total}", + ProviderName, operationName, + usage.PromptTokens ?? 0, + usage.CompletionTokens ?? 0, + usage.TotalTokens ?? 0); + } + } + + /// + /// Begins an instrumentation scope for a streaming provider request. + /// Use inside an async iterator with try/finally; call RecordChunk() per chunk + /// and RecordFailure(...) before re-throwing on error. + /// + /// The operation name (e.g., "StreamChatCompletion"). + protected ProviderInstrumentation.StreamingScope BeginStreamingScope(string operationName) + { + return ProviderInstrumentation.BeginStreaming( + operationName, ProviderName, ProviderTypeName, ProviderModelId); + } + + /// + /// Begins an instrumentation scope for a long-running async-job poll loop + /// (e.g., Replicate predictions, MiniMax video generation). + /// Pair with using and pass to AsyncJobPoller.PollAsync. + /// + /// The operation name (e.g., "CreateVideo", "CreateImage"). + protected ProviderInstrumentation.PollingScope BeginPollingScope(string operationName) + { + return ProviderInstrumentation.BeginPolling( + operationName, ProviderName, ProviderTypeName, ProviderModelId); } /// @@ -319,6 +492,7 @@ protected virtual void ValidateRequest(TRequest request, string operat /// /// Creates a dictionary of standard headers for API requests. + /// Uses the property to determine the authentication header. /// /// Optional API key to override the one in credentials. /// A dictionary of headers. @@ -331,9 +505,17 @@ protected virtual Dictionary CreateStandardHeaders(string? apiKe ["User-Agent"] = "ConduitLLM" }; - // Add authentication - default to Bearer - // Override in derived classes to use different auth methods - headers["Authorization"] = $"Bearer {effectiveApiKey}"; + // Add authentication using the strategy + var authHeader = AuthenticationStrategy.CreateAuthenticationHeader(effectiveApiKey); + if (authHeader != null) + { + headers["Authorization"] = $"{authHeader.Scheme} {authHeader.Parameter}"; + } + else if (AuthenticationStrategy is ApiKeyHeaderStrategy apiKeyStrategy) + { + // For header-based auth strategies that don't use Authorization header + headers[apiKeyStrategy.HeaderName] = effectiveApiKey; + } return headers; } @@ -346,40 +528,93 @@ protected virtual Dictionary CreateStandardHeaders(string? apiKe /// Cancellation token for the operation. /// An authentication result indicating success or failure. /// - /// This default implementation performs a basic check that the API key exists. - /// Derived classes should override this method to implement provider-specific - /// authentication verification logic. + /// This implementation makes an actual HTTP request to verify the API key works. + /// It uses to determine the endpoint. + /// Derived classes can override this for provider-specific verification logic, + /// or just override if only the endpoint differs. /// public virtual async Task VerifyAuthenticationAsync( string? apiKey = null, string? baseUrl = null, CancellationToken cancellationToken = default) { + var startTime = DateTime.UtcNow; + try { // Use provided API key or fall back to configured one var effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey; - + // Basic validation if (string.IsNullOrWhiteSpace(effectiveApiKey)) { return Core.Interfaces.AuthenticationResult.Failure( "API key is required", - "No API key provided for authentication verification"); + $"No API key provided for {ProviderName} authentication"); + } + + // Create HTTP client and make verification request + using var client = CreateAuthenticationVerificationClient(effectiveApiKey); + var healthCheckUrl = GetHealthCheckUrl(baseUrl); + + Logger.LogDebug("Verifying {Provider} authentication with endpoint: {Endpoint}", ProviderName, healthCheckUrl); + + using var response = await client.GetAsync(healthCheckUrl, cancellationToken); + var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; + + Logger.LogInformation("{Provider} auth check returned status {StatusCode}", ProviderName, response.StatusCode); + + if (response.IsSuccessStatusCode) + { + return Core.Interfaces.AuthenticationResult.Success( + $"Connected successfully to {ProviderName}", + responseTime); + } + + // Handle specific error cases + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + Logger.LogWarning("{Provider} authentication failed: {Response}", ProviderName, responseContent); + return Core.Interfaces.AuthenticationResult.Failure( + "Authentication failed", + $"Invalid API key for {ProviderName}"); } - // For base implementation, just verify key exists - // Derived classes should override with actual API calls - Logger.LogInformation("Basic authentication check passed for {Provider}", ProviderName); - - // Return completed task to make this properly async - await Task.CompletedTask; - - return Core.Interfaces.AuthenticationResult.Success($"Authentication verified for {ProviderName}"); + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return Core.Interfaces.AuthenticationResult.Failure( + "Access forbidden", + $"API key does not have sufficient permissions for {ProviderName}"); + } + + return Core.Interfaces.AuthenticationResult.Failure( + $"Unexpected response: {response.StatusCode}", + responseContent); + } + catch (HttpRequestException ex) + { + Logger.LogError(ex, "Network error verifying {Provider} authentication", ProviderName); + return Core.Interfaces.AuthenticationResult.Failure( + $"Network error: {ex.Message}", + ex.ToString()); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + Logger.LogError(ex, "Timeout verifying {Provider} authentication", ProviderName); + return Core.Interfaces.AuthenticationResult.Failure( + "Request timeout", + "Authentication request timed out"); + } + catch (OperationCanceledException) + { + Logger.LogDebug("{Provider} authentication verification was cancelled", ProviderName); + throw; } catch (Exception ex) { - Logger.LogError(ex, "Error verifying authentication for {Provider}", ProviderName); + Logger.LogError(ex, "Error verifying {Provider} authentication", ProviderName); return Core.Interfaces.AuthenticationResult.Failure( $"Authentication verification failed: {ex.Message}", ex.ToString()); @@ -392,16 +627,19 @@ protected virtual Dictionary CreateStandardHeaders(string? apiKe /// Optional base URL override. If null, uses the configured URL. /// The URL to use for health checks. /// - /// This default implementation returns a generic /health endpoint. - /// Derived classes should override this method to return provider-specific URLs. + /// This default implementation uses the health check endpoint from the provider configuration registry, + /// falling back to the /models endpoint which is commonly used by OpenAI-compatible APIs. + /// Derived classes can override this method for provider-specific endpoints. /// public virtual string GetHealthCheckUrl(string? baseUrl = null) { - var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) - ? baseUrl.TrimEnd('/') + var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) + ? baseUrl.TrimEnd('/') : (Provider.BaseUrl ?? GetDefaultBaseUrl()).TrimEnd('/'); - - return $"{effectiveBaseUrl}/health"; + + // Use the health check endpoint from configuration, or default to /models + var healthCheckEndpoint = ProviderConfigurationRegistry.GetHealthCheckEndpoint(Provider.ProviderType); + return $"{effectiveBaseUrl}{healthCheckEndpoint}"; } /// @@ -409,11 +647,14 @@ public virtual string GetHealthCheckUrl(string? baseUrl = null) /// /// The default base URL. /// - /// Override in derived classes to provide provider-specific default URLs. + /// Uses the default URL from the provider configuration registry. + /// Override in derived classes to provide provider-specific default URLs + /// if not defined in the registry. /// protected virtual string GetDefaultBaseUrl() { - return "https://api.example.com"; + return ProviderConfigurationRegistry.GetDefaultBaseUrl(Provider.ProviderType) + ?? "https://api.example.com"; } /// @@ -453,14 +694,147 @@ protected virtual ProviderErrorType ClassifyHttpError( /// The response body for additional context. /// The refined error type. protected virtual ProviderErrorType RefineErrorClassification( - ProviderErrorType baseType, + ProviderErrorType baseType, string? responseBody) { - // Base implementation returns the status code-based classification - // Derived classes should override to parse provider-specific error messages + if (string.IsNullOrEmpty(responseBody)) + return baseType; + + var lowerBody = responseBody.ToLowerInvariant(); + + // Common pattern: 403 with quota/billing keywords โ†’ InsufficientBalance + if (baseType == ProviderErrorType.AccessForbidden && + (lowerBody.Contains("insufficient_quota") || + lowerBody.Contains("exceeded your current quota") || + lowerBody.Contains("billing") || + lowerBody.Contains("payment") || + lowerBody.Contains("credit"))) + { + return ProviderErrorType.InsufficientBalance; + } + + // Common pattern: authentication method mismatch (e.g., Bearer token + // sent to a provider that requires a custom API key header) + if (baseType == ProviderErrorType.InvalidApiKey && + (lowerBody.Contains("invalid bearer token") || + lowerBody.Contains("invalid api key") || + lowerBody.Contains("invalid x-api-key") || + lowerBody.Contains("authentication_error"))) + { + return ProviderErrorType.InvalidApiKey; + } + + // Common pattern: rate limit keywords regardless of status code + if (lowerBody.Contains("rate limit") || + lowerBody.Contains("too many requests")) + { + return ProviderErrorType.RateLimitExceeded; + } + + // Common pattern: model not found keywords + if (lowerBody.Contains("model") && + (lowerBody.Contains("not found") || + lowerBody.Contains("does not exist") || + lowerBody.Contains("invalid model"))) + { + return ProviderErrorType.ModelNotFound; + } + return baseType; } + /// + /// Extracts a more helpful error message from exception details. + /// Checks Response: patterns, embedded JSON, Data["Body"], and InnerException. + /// + /// The exception to extract information from. + /// An enhanced error message. + protected virtual string ExtractEnhancedErrorMessage(Exception ex) + { + // 1. Look for "Response:" pattern in the message + var msg = ex.Message; + var responseIdx = msg.IndexOf("Response:"); + if (responseIdx >= 0) + { + var extracted = msg.Substring(responseIdx + "Response:".Length).Trim(); + if (!string.IsNullOrEmpty(extracted)) + { + return extracted; + } + } + + // 2. Look for JSON content in the message + var jsonStart = msg.IndexOf("{"); + var jsonEnd = msg.LastIndexOf("}"); + if (jsonStart >= 0 && jsonEnd > jsonStart) + { + var jsonPart = msg.Substring(jsonStart, jsonEnd - jsonStart + 1); + try + { + var json = JsonDocument.Parse(jsonPart); + if (json.RootElement.TryGetProperty("error", out var errorElement)) + { + if (errorElement.TryGetProperty("message", out var messageElement)) + { + return messageElement.GetString() ?? msg; + } + } + } + catch + { + // If parsing fails, continue to the next method + } + } + + // 3. Look for Body data in the exception's Data dictionary + if (ex.Data.Contains("Body") && ex.Data["Body"] is string body && !string.IsNullOrEmpty(body)) + { + return body; + } + + // 4. Try inner exception + if (ex.InnerException != null && !string.IsNullOrEmpty(ex.InnerException.Message)) + { + return ex.InnerException.Message; + } + + // 5. Fallback to original message + return msg; + } + + /// + /// Extracts a user-friendly error message from a JSON string by checking common error paths: + /// error.message, error (as string), and message. + /// + /// The JSON content to parse. + /// The fallback message if parsing fails. + /// The extracted error message or the fallback. + protected static string ExtractErrorFromJson(string jsonContent, string fallback) + { + try + { + var json = JsonDocument.Parse(jsonContent); + + if (json.RootElement.TryGetProperty("error", out var error)) + { + if (error.TryGetProperty("message", out var message)) + return message.GetString() ?? fallback; + + if (error.ValueKind == JsonValueKind.String) + return error.GetString() ?? fallback; + } + + if (json.RootElement.TryGetProperty("message", out var directMessage)) + return directMessage.GetString() ?? fallback; + } + catch + { + // Not JSON or parsing failed + } + + return fallback; + } + /// /// Extracts a user-friendly error message from an HTTP response. /// @@ -483,34 +857,14 @@ protected virtual async Task ExtractErrorMessageAsync( } } - // Try to parse JSON error message + var fallback = $"{response.StatusCode}: {response.ReasonPhrase ?? "Unknown error"}"; + if (!string.IsNullOrEmpty(responseBody)) { - try - { - var json = JsonDocument.Parse(responseBody); - - // Common error message patterns - if (json.RootElement.TryGetProperty("error", out var error)) - { - if (error.TryGetProperty("message", out var message)) - return message.GetString() ?? responseBody; - - if (error.ValueKind == JsonValueKind.String) - return error.GetString() ?? responseBody; - } - - if (json.RootElement.TryGetProperty("message", out var directMessage)) - return directMessage.GetString() ?? responseBody; - } - catch - { - // Not JSON or parsing failed - } + return ExtractErrorFromJson(responseBody, fallback); } - // Fallback to status code description - return $"{response.StatusCode}: {response.ReasonPhrase ?? "Unknown error"}"; + return fallback; } /// diff --git a/Shared/ConduitLLM.Providers/ConduitLLM.Providers.csproj b/Shared/ConduitLLM.Providers/ConduitLLM.Providers.csproj index f15a23c47..2c42c2283 100644 --- a/Shared/ConduitLLM.Providers/ConduitLLM.Providers.csproj +++ b/Shared/ConduitLLM.Providers/ConduitLLM.Providers.csproj @@ -6,16 +6,16 @@ - - - - - + + + + + - - + + diff --git a/Shared/ConduitLLM.Providers/Configuration/ClientCreatorRegistry.cs b/Shared/ConduitLLM.Providers/Configuration/ClientCreatorRegistry.cs new file mode 100644 index 000000000..521680fad --- /dev/null +++ b/Shared/ConduitLLM.Providers/Configuration/ClientCreatorRegistry.cs @@ -0,0 +1,325 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Providers.Cerebras; +using ConduitLLM.Providers.DeepInfra; +using ConduitLLM.Providers.Fireworks; +using ConduitLLM.Providers.Groq; +using ConduitLLM.Providers.MiniMax; +using ConduitLLM.Providers.OpenAI; +using ConduitLLM.Providers.Replicate; +using ConduitLLM.Providers.Cloudflare; +using ConduitLLM.Providers.OpenRouter; +using ConduitLLM.Providers.SambaNova; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Providers.Configuration +{ + /// + /// Delegate for creating LLM client instances. + /// + /// The provider configuration. + /// The API key credential. + /// The model ID to use. + /// The creation context with dependencies. + /// The created LLM client instance. + public delegate ILLMClient ClientCreatorDelegate( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context); + + /// + /// Context containing dependencies needed for client creation. + /// + public record ClientCreationContext + { + /// + /// The logger factory for creating typed loggers. + /// + public required ILoggerFactory LoggerFactory { get; init; } + + /// + /// The HTTP client factory for creating HTTP clients. + /// + public required IHttpClientFactory HttpClientFactory { get; init; } + + /// + /// Optional model capability service for capability detection. + /// + public IModelCapabilityService? CapabilityService { get; init; } + + /// + /// Optional default models configuration. + /// + public ProviderDefaultModels? DefaultModels { get; init; } + } + + /// + /// Registry for creating LLM clients based on provider type. + /// Eliminates the need for switch statements in client factories. + /// + public static class ClientCreatorRegistry + { + /// + /// Registry of client creators keyed by ProviderType. + /// + private static readonly Dictionary Creators = new() + { + [ProviderType.OpenAI] = CreateOpenAIClient, + [ProviderType.Groq] = CreateGroqClient, + [ProviderType.Replicate] = CreateReplicateClient, + [ProviderType.Fireworks] = CreateFireworksClient, + [ProviderType.OpenAICompatible] = CreateOpenAICompatibleClient, + [ProviderType.MiniMax] = CreateMiniMaxClient, + [ProviderType.Cerebras] = CreateCerebrasClient, + [ProviderType.SambaNova] = CreateSambaNovaClient, + [ProviderType.DeepInfra] = CreateDeepInfraClient, + [ProviderType.Cloudflare] = CreateCloudflareClient, + [ProviderType.OpenRouter] = CreateOpenRouterClient + }; + + /// + /// Gets the client creator for a provider type. + /// + /// The provider type. + /// The client creator delegate, or null if not found. + public static ClientCreatorDelegate? GetCreator(ProviderType providerType) + { + return Creators.TryGetValue(providerType, out var creator) ? creator : null; + } + + /// + /// Tries to get the client creator for a provider type. + /// + /// The provider type. + /// The creator if found. + /// True if found, false otherwise. + public static bool TryGetCreator(ProviderType providerType, out ClientCreatorDelegate? creator) + { + return Creators.TryGetValue(providerType, out creator); + } + + /// + /// Creates a client for the specified provider type. + /// + /// The provider type. + /// The provider configuration. + /// The API key credential. + /// The model ID to use. + /// The creation context with dependencies. + /// The created LLM client instance. + /// Thrown when the provider type is not supported. + public static ILLMClient CreateClient( + ProviderType providerType, + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + if (!TryGetCreator(providerType, out var creator) || creator == null) + { + throw new ArgumentException($"Unsupported provider type: {providerType}", nameof(providerType)); + } + + return creator(provider, keyCredential, modelId, context); + } + + /// + /// Checks if a provider type is supported. + /// + /// The provider type to check. + /// True if supported, false otherwise. + public static bool IsSupported(ProviderType providerType) + { + return Creators.ContainsKey(providerType); + } + + /// + /// Gets all supported provider types. + /// + /// Collection of supported provider types. + public static IEnumerable GetSupportedProviderTypes() + { + return Creators.Keys; + } + + private static ILLMClient CreateOpenAIClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new OpenAIClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.CapabilityService, + context.DefaultModels); + } + + private static ILLMClient CreateGroqClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new GroqClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateReplicateClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new ReplicateClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateFireworksClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new FireworksClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateOpenAICompatibleClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new OpenAICompatibleGenericClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateMiniMaxClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new MiniMaxClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateCerebrasClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new CerebrasClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateSambaNovaClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new SambaNovaClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateDeepInfraClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new DeepInfraClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateCloudflareClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new CloudflareClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + + private static ILLMClient CreateOpenRouterClient( + Provider provider, + ProviderKeyCredential keyCredential, + string modelId, + ClientCreationContext context) + { + var logger = context.LoggerFactory.CreateLogger(); + return new OpenRouterClient( + provider, + keyCredential, + modelId, + logger, + context.HttpClientFactory, + context.DefaultModels); + } + } +} diff --git a/Shared/ConduitLLM.Providers/Configuration/ProviderConfigurationRegistry.cs b/Shared/ConduitLLM.Providers/Configuration/ProviderConfigurationRegistry.cs new file mode 100644 index 000000000..1eb851c90 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Configuration/ProviderConfigurationRegistry.cs @@ -0,0 +1,400 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Providers.Authentication; + +namespace ConduitLLM.Providers.Configuration +{ + /// + /// Centralized registry for provider configurations. + /// Eliminates duplicated Constants classes across provider implementations. + /// + public static class ProviderConfigurationRegistry + { + /// + /// Registry of provider configurations keyed by ProviderType. + /// + private static readonly Dictionary Configurations = new() + { + [ProviderType.OpenAI] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.openai.com/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + EmbeddingsEndpoint = "/embeddings", + ImageGenerationsEndpoint = "/images/generations", + AudioTranscriptionsEndpoint = "/audio/transcriptions", + AudioSpeechEndpoint = "/audio/speech", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for OpenAI. Please verify your API key is correct.", + RateLimitExceeded = "OpenAI API rate limit exceeded. Please try again later.", + InsufficientBalance = "Insufficient balance in your OpenAI account.", + ModelNotFound = "Model not found. Please verify the model ID is correct." + } + }, + + [ProviderType.Groq] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.groq.com/openai/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for Groq. Please verify your API key is correct.", + RateLimitExceeded = "Groq API rate limit exceeded. Please try again later or reduce your request frequency.", + ModelNotFound = "Model not found. Available Groq models include: llama3-8b-8192, llama3-70b-8192, mixtral-8x7b-32768, gemma-7b-it" + } + }, + + [ProviderType.Fireworks] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.fireworks.ai/inference/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + EmbeddingsEndpoint = "/embeddings", + ImageGenerationsEndpoint = "/images/generations", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for Fireworks. Please verify your API key is correct.", + RateLimitExceeded = "Fireworks API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Please verify the model ID is correct." + } + }, + + [ProviderType.Cerebras] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.cerebras.ai/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for Cerebras. Please verify your API key is correct.", + RateLimitExceeded = "Cerebras API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Please verify the model ID is correct.", + MissingApiKey = "API key is required for Cerebras" + } + }, + + [ProviderType.SambaNova] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.sambanova.ai/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for SambaNova. Please verify your API key is correct.", + RateLimitExceeded = "SambaNova API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Please verify the model ID is correct." + } + }, + + [ProviderType.DeepInfra] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.deepinfra.com/v1/openai", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + EmbeddingsEndpoint = "/embeddings", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for DeepInfra. Please verify your API key is correct.", + RateLimitExceeded = "DeepInfra API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Please verify the model ID is correct." + } + }, + + [ProviderType.Cloudflare] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + EmbeddingsEndpoint = "/embeddings", + SupportsModelsList = false, + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API token for Cloudflare. Please verify your Cloudflare API token is correct.", + RateLimitExceeded = "Cloudflare Workers AI rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Cloudflare Workers AI models use the @cf/provider/model-name format.", + MissingApiKey = "API token is required for Cloudflare Workers AI" + } + }, + + [ProviderType.Replicate] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.replicate.com/v1", + ModelsEndpoint = "/models", + HealthCheckEndpoint = "/account", + AuthenticationStrategy = TokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API token for Replicate. Please verify your API token is correct.", + RateLimitExceeded = "Replicate API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Please verify the model version hash is correct." + } + }, + + [ProviderType.MiniMax] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.minimax.chat/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/text/chatcompletion_v2", + AuthenticationStrategy = BearerTokenStrategy.Instance, + SupportsModelsList = false, // MiniMax doesn't support listing models + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for MiniMax. Please verify your API key is correct.", + RateLimitExceeded = "MiniMax API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Please verify the model ID is correct." + } + }, + + [ProviderType.OpenAICompatible] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.openai.com/v1", // Will be overridden by provider config + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + EmbeddingsEndpoint = "/embeddings", + ImageGenerationsEndpoint = "/images/generations", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key. Please verify your API key is correct.", + RateLimitExceeded = "API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. Please verify the model ID is correct." + } + }, + + [ProviderType.Ultravox] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.ultravox.ai/v1", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for Ultravox. Please verify your API key is correct.", + RateLimitExceeded = "Ultravox API rate limit exceeded. Please try again later." + } + }, + + [ProviderType.ElevenLabs] = new ProviderConfiguration + { + DefaultBaseUrl = "https://api.elevenlabs.io/v1", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for ElevenLabs. Please verify your API key is correct.", + RateLimitExceeded = "ElevenLabs API rate limit exceeded. Please try again later." + } + }, + + [ProviderType.OpenRouter] = new ProviderConfiguration + { + DefaultBaseUrl = "https://openrouter.ai/api/v1", + ModelsEndpoint = "/models", + ChatCompletionsEndpoint = "/chat/completions", + AuthenticationStrategy = BearerTokenStrategy.Instance, + ErrorMessages = new ProviderErrorMessages + { + InvalidApiKey = "Invalid API key for OpenRouter. Please verify your API key is correct.", + RateLimitExceeded = "OpenRouter API rate limit exceeded. Please try again later.", + ModelNotFound = "Model not found. OpenRouter models use provider/model-name format (e.g., openai/gpt-4o)." + } + } + }; + + /// + /// Gets the configuration for a provider type. + /// + /// The provider type. + /// The provider configuration, or null if not found. + public static ProviderConfiguration? GetConfiguration(ProviderType providerType) + { + return Configurations.TryGetValue(providerType, out var config) ? config : null; + } + + /// + /// Tries to get the configuration for a provider type. + /// + /// The provider type. + /// The configuration if found. + /// True if found, false otherwise. + public static bool TryGetConfiguration(ProviderType providerType, out ProviderConfiguration? configuration) + { + return Configurations.TryGetValue(providerType, out configuration); + } + + /// + /// Gets the default base URL for a provider type. + /// + /// The provider type. + /// The default base URL, or null if not found. + public static string? GetDefaultBaseUrl(ProviderType providerType) + { + return GetConfiguration(providerType)?.DefaultBaseUrl; + } + + /// + /// Gets the authentication strategy for a provider type. + /// + /// The provider type. + /// The authentication strategy, or BearerTokenStrategy as default. + public static IAuthenticationStrategy GetAuthenticationStrategy(ProviderType providerType) + { + return GetConfiguration(providerType)?.AuthenticationStrategy ?? BearerTokenStrategy.Instance; + } + + /// + /// Gets the health check endpoint for a provider type. + /// Returns the models endpoint by default if no specific health check endpoint is defined. + /// + /// The provider type. + /// The health check endpoint path. + public static string GetHealthCheckEndpoint(ProviderType providerType) + { + var config = GetConfiguration(providerType); + if (config == null) + { + return "/models"; + } + + return config.HealthCheckEndpoint ?? config.ModelsEndpoint ?? "/models"; + } + + /// + /// Gets error messages for a provider type. + /// + /// The provider type. + /// The error messages, or default messages if not found. + public static ProviderErrorMessages GetErrorMessages(ProviderType providerType) + { + return GetConfiguration(providerType)?.ErrorMessages ?? ProviderErrorMessages.Default; + } + + /// + /// Checks if a provider supports listing models. + /// + /// The provider type. + /// True if the provider supports listing models, false otherwise. + public static bool SupportsModelsList(ProviderType providerType) + { + var config = GetConfiguration(providerType); + return config?.SupportsModelsList ?? true; + } + } + + /// + /// Configuration for an LLM provider. + /// + public record ProviderConfiguration + { + /// + /// The default base URL for the provider's API. + /// + public required string DefaultBaseUrl { get; init; } + + /// + /// The endpoint path for listing models (e.g., "/models"). + /// + public string? ModelsEndpoint { get; init; } + + /// + /// The endpoint path for chat completions (e.g., "/chat/completions"). + /// + public string? ChatCompletionsEndpoint { get; init; } + + /// + /// The endpoint path for embeddings (e.g., "/embeddings"). + /// + public string? EmbeddingsEndpoint { get; init; } + + /// + /// The endpoint path for image generations (e.g., "/images/generations"). + /// + public string? ImageGenerationsEndpoint { get; init; } + + /// + /// The endpoint path for audio transcriptions. + /// + public string? AudioTranscriptionsEndpoint { get; init; } + + /// + /// The endpoint path for audio speech synthesis. + /// + public string? AudioSpeechEndpoint { get; init; } + + /// + /// The endpoint path for health checks. If null, uses ModelsEndpoint. + /// + public string? HealthCheckEndpoint { get; init; } + + /// + /// The authentication strategy to use for this provider. + /// + public required IAuthenticationStrategy AuthenticationStrategy { get; init; } + + /// + /// Whether this provider supports listing available models. + /// Defaults to true. + /// + public bool SupportsModelsList { get; init; } = true; + + /// + /// Error messages specific to this provider. + /// + public required ProviderErrorMessages ErrorMessages { get; init; } + } + + /// + /// Provider-specific error messages. + /// + public record ProviderErrorMessages + { + // Default message constants to avoid circular initialization + private const string DefaultInvalidApiKey = "Invalid API key. Please verify your API key is correct."; + private const string DefaultRateLimitExceeded = "API rate limit exceeded. Please try again later."; + private const string DefaultModelNotFound = "Model not found. Please verify the model ID is correct."; + private const string DefaultInsufficientBalance = "Insufficient balance in your account."; + private const string DefaultMissingApiKey = "API key is required."; + + /// + /// Default error messages for unknown providers. + /// + public static readonly ProviderErrorMessages Default = new() + { + InvalidApiKey = DefaultInvalidApiKey, + RateLimitExceeded = DefaultRateLimitExceeded, + ModelNotFound = DefaultModelNotFound, + InsufficientBalance = DefaultInsufficientBalance, + MissingApiKey = DefaultMissingApiKey + }; + + /// + /// Message for invalid API key errors. + /// + public string InvalidApiKey { get; init; } = DefaultInvalidApiKey; + + /// + /// Message for rate limit exceeded errors. + /// + public string RateLimitExceeded { get; init; } = DefaultRateLimitExceeded; + + /// + /// Message for model not found errors. + /// + public string ModelNotFound { get; init; } = DefaultModelNotFound; + + /// + /// Message for insufficient balance errors. + /// + public string InsufficientBalance { get; init; } = DefaultInsufficientBalance; + + /// + /// Message for missing API key errors. + /// + public string MissingApiKey { get; init; } = DefaultMissingApiKey; + } +} diff --git a/Shared/ConduitLLM.Providers/Configuration/ProviderHttpClientOptions.cs b/Shared/ConduitLLM.Providers/Configuration/ProviderHttpClientOptions.cs new file mode 100644 index 000000000..deac8e042 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Configuration/ProviderHttpClientOptions.cs @@ -0,0 +1,122 @@ +using System.ComponentModel.DataAnnotations; + +namespace ConduitLLM.Providers.Configuration; + +/// +/// Consolidated configuration options for HTTP clients used by LLM provider clients. +/// Provides consistent timeout and retry settings across all providers. +/// +public class ProviderHttpClientOptions +{ + /// + /// The configuration section name. + /// + public const string SectionName = "ConduitLLM:HttpClient"; + + /// + /// Default timeout for standard API requests (e.g., chat completions). + /// Default: 120 seconds. + /// + [Range(5, 600)] + public int DefaultTimeoutSeconds { get; set; } = 120; + + /// + /// Timeout for authentication verification requests. + /// Should be shorter since these are simple health checks. + /// Default: 30 seconds. + /// + [Range(5, 120)] + public int AuthVerificationTimeoutSeconds { get; set; } = 30; + + /// + /// Timeout for image generation requests. + /// Default: 180 seconds (3 minutes). + /// + [Range(30, 600)] + public int ImageGenerationTimeoutSeconds { get; set; } = 180; + + /// + /// Timeout for video generation requests. + /// Video generation can take a long time. + /// Default: 600 seconds (10 minutes). + /// + [Range(60, 3600)] + public int VideoGenerationTimeoutSeconds { get; set; } = 600; + + /// + /// Timeout for large file downloads (e.g., video files). + /// Default: 1800 seconds (30 minutes). + /// + [Range(60, 7200)] + public int LargeFileDownloadTimeoutSeconds { get; set; } = 1800; + + /// + /// Timeout for polling operations (checking async task status). + /// Default: 30 seconds per poll request. + /// + [Range(5, 120)] + public int PollingTimeoutSeconds { get; set; } = 30; + + /// + /// Maximum duration for video generation polling loops. + /// Default: 900 seconds (15 minutes). + /// + [Range(60, 3600)] + public int VideoPollingMaxDurationSeconds { get; set; } = 900; + + /// + /// Whether to log timeout events. + /// Default: true. + /// + public bool EnableTimeoutLogging { get; set; } = true; + + /// + /// Gets the timeout for a specific operation type. + /// + /// The type of operation. + /// The timeout as a TimeSpan. + public TimeSpan GetTimeout(ProviderOperationType operationType) + { + return operationType switch + { + ProviderOperationType.AuthVerification => TimeSpan.FromSeconds(AuthVerificationTimeoutSeconds), + ProviderOperationType.ChatCompletion => TimeSpan.FromSeconds(DefaultTimeoutSeconds), + ProviderOperationType.ImageGeneration => TimeSpan.FromSeconds(ImageGenerationTimeoutSeconds), + ProviderOperationType.VideoGeneration => TimeSpan.FromSeconds(VideoGenerationTimeoutSeconds), + ProviderOperationType.LargeFileDownload => TimeSpan.FromSeconds(LargeFileDownloadTimeoutSeconds), + ProviderOperationType.Polling => TimeSpan.FromSeconds(PollingTimeoutSeconds), + ProviderOperationType.VideoPolling => TimeSpan.FromSeconds(VideoPollingMaxDurationSeconds), + _ => TimeSpan.FromSeconds(DefaultTimeoutSeconds) + }; + } +} + +/// +/// Types of operations that can have different timeout configurations. +/// +public enum ProviderOperationType +{ + /// Standard API request (default). + Default, + + /// Authentication verification request. + AuthVerification, + + /// Chat completion request. + ChatCompletion, + + /// Image generation request. + ImageGeneration, + + /// Video generation request. + VideoGeneration, + + /// Large file download (e.g., video files). + LargeFileDownload, + + /// Polling for async task status. + Polling, + + /// Video generation polling loop. + VideoPolling +} diff --git a/Shared/ConduitLLM.Providers/CustomProviderClient.cs b/Shared/ConduitLLM.Providers/CustomProviderClient.cs deleted file mode 100644 index 68e54f7d6..000000000 --- a/Shared/ConduitLLM.Providers/CustomProviderClient.cs +++ /dev/null @@ -1,286 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -using ConduitLLM.Configuration; -using ConduitLLM.Configuration.Entities; -using ConduitLLM.Core.Exceptions; -using ConduitLLM.Core.Models; - -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Providers -{ - /// - /// Base class for LLM client implementations with custom APIs that don't follow the OpenAI-compatible pattern. - /// - /// - /// - /// This abstract class serves as a foundation for providers with unique API formats that - /// differ significantly from the OpenAI API structure, such as Anthropic, Cohere, Gemini, and others. - /// - /// - /// It provides a standardized implementation framework for the interface - /// with customizable methods that derived classes must implement to accommodate provider-specific - /// behaviors while maintaining a consistent interface. - /// - /// - /// Key features: - /// - Common API request and error handling utilities - /// - Centralized credential validation and HTTP client configuration - /// - Standardized logging and exception management - /// - Abstractions for provider-specific API request and response mapping - /// - /// - /// Derived classes must implement the abstract methods to provide provider-specific functionality - /// and can override virtual methods to customize behavior as needed. - /// - /// - public abstract class CustomProviderClient : BaseLLMClient - { - /// - /// Gets the base URL for API requests. - /// - protected readonly string BaseUrl; - - /// - /// Gets the default JSON serialization options. - /// - protected static readonly JsonSerializerOptions DefaultSerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false - }; - - /// - /// Initializes a new instance of the class. - /// - /// The provider credentials. - /// The provider's model identifier. - /// The logger to use. - /// Optional HTTP client factory. - /// The name of this LLM provider. - /// The base URL for API requests. - /// Optional default model configuration for the provider. - protected CustomProviderClient( - Provider provider, - ProviderKeyCredential keyCredential, - string providerModelId, - ILogger logger, - IHttpClientFactory? httpClientFactory = null, - string? providerName = null, - string? baseUrl = null, - ProviderDefaultModels? defaultModels = null) - : base(provider, keyCredential, providerModelId, logger, httpClientFactory, providerName, defaultModels) - { - BaseUrl = !string.IsNullOrEmpty(baseUrl) - ? baseUrl - : !string.IsNullOrEmpty(provider.BaseUrl) - ? provider.BaseUrl - : throw new ConfigurationException($"Base URL must be provided either directly or via BaseUrl in provider for {ProviderName}"); - } - - /// - /// Configures the HttpClient with provider-specific settings. - /// - /// The HTTP client to configure. - /// The API key to use for authentication. - /// - /// This method adds standard headers and sets the base address for the HTTP client. - /// Derived classes should override this method to provide provider-specific configuration - /// and call the base implementation to ensure common configuration is applied. - /// - protected override void ConfigureHttpClient(HttpClient client, string apiKey) - { - base.ConfigureHttpClient(client, apiKey); - - // Set the base address if not already set - if (client.BaseAddress == null && !string.IsNullOrEmpty(BaseUrl)) - { - client.BaseAddress = new Uri(BaseUrl.TrimEnd('/')); - } - } - - /// - /// Validates a request before sending it to the API. - /// - /// The type of the request. - /// The request to validate. - /// The name of the operation for error messages. - /// - /// This method performs basic validation on the request. - /// Derived classes should override this method to add provider-specific validation - /// and call the base implementation to ensure common validation is performed. - /// - protected override void ValidateRequest(TRequest request, string operationName) - { - base.ValidateRequest(request, operationName); - - // Add common validation for CustomProviderClient - if (request is ChatCompletionRequest chatRequest) - { - if (chatRequest.Messages == null || chatRequest.Messages.Count() == 0) - { - throw new ValidationException($"{operationName}: Messages cannot be null or empty"); - } - } - else if (request is EmbeddingRequest embeddingRequest) - { - if (embeddingRequest.Input == null) - { - throw new ValidationException($"{operationName}: Input cannot be null"); - } - } - else if (request is ImageGenerationRequest imageRequest) - { - if (string.IsNullOrWhiteSpace(imageRequest.Prompt)) - { - throw new ValidationException($"{operationName}: Prompt cannot be null or empty"); - } - } - } - - /// - /// Extracts a detailed error message from an HTTP response. - /// - /// The HTTP response. - /// The error content as a string. - /// A detailed error message. - /// - /// This method attempts to extract a meaningful error message from a provider's error response. - /// Different providers format their error messages differently, so derived classes should - /// override this method to provide provider-specific error extraction logic. - /// - protected virtual string ExtractErrorDetails(HttpResponseMessage response, string errorJsonContent) - { - if (string.IsNullOrEmpty(errorJsonContent)) - { - return $"HTTP error {(int)response.StatusCode}: {response.ReasonPhrase}"; - } - - // Try to parse as JSON to extract error message - try - { - var errorJson = JsonDocument.Parse(errorJsonContent); - var errorRoot = errorJson.RootElement; - - // Try common error message paths - if (errorRoot.TryGetProperty("error", out var errorObj)) - { - if (errorObj.TryGetProperty("message", out var messageObj)) - { - return messageObj.GetString() ?? errorJsonContent; - } - } - - // Try other common patterns - if (errorRoot.TryGetProperty("message", out var directMessageObj)) - { - return directMessageObj.GetString() ?? errorJsonContent; - } - - // Just return the raw content if we couldn't extract - return errorJsonContent; - } - catch - { - // If parsing fails, return the raw content - return errorJsonContent; - } - } - - /// - /// Creates a final chat completion response when using custom mapping from provider-specific format. - /// - /// The content of the response. - /// The model used for generation. - /// The number of tokens in the prompt. - /// The number of tokens in the completion. - /// The reason the generation finished. - /// The original model alias requested. - /// A standardized chat completion response. - /// - /// This helper method creates a standardized chat completion response in the format expected - /// by consumers of the ILLMClient interface. It helps derived classes implement consistent - /// response mapping from provider-specific formats. - /// - protected ChatCompletionResponse CreateChatCompletionResponse( - string content, - string model, - int promptTokens, - int completionTokens, - string? finishReason = "stop", - string? originalModelAlias = null) - { - return new ChatCompletionResponse - { - Id = $"chatcmpl-{Guid.NewGuid():N}", - Object = "chat.completion", - Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - Model = model, - Choices = new List - { - new Choice - { - Index = 0, - Message = new Message - { - Role = "assistant", - Content = content - }, - FinishReason = finishReason ?? "stop" - } - }, - Usage = new Usage - { - PromptTokens = promptTokens, - CompletionTokens = completionTokens, - TotalTokens = promptTokens + completionTokens - } - }; - } - - /// - /// Creates a chat completion chunk for streaming responses when using custom mapping. - /// - /// The content of the chunk. - /// The model used for generation. - /// Whether this is the first chunk in the stream. - /// The reason the generation finished, only for final chunks. - /// The original model alias requested. - /// A standardized chat completion chunk. - /// - /// This helper method creates a standardized chat completion chunk in the format expected - /// by consumers of the ILLMClient interface. It helps derived classes implement consistent - /// streaming response mapping from provider-specific formats. - /// - protected ChatCompletionChunk CreateChatCompletionChunk( - string content, - string model, - bool isFirst = false, - string? finishReason = null, - string? originalModelAlias = null) - { - return new ChatCompletionChunk - { - Id = $"chatcmpl-{Guid.NewGuid():N}", - Object = "chat.completion.chunk", - Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - Model = model, - Choices = new List - { - new StreamingChoice - { - Index = 0, - Delta = new DeltaContent - { - Role = isFirst ? "assistant" : null, - Content = content - }, - FinishReason = finishReason - } - } - }; - } - } -} diff --git a/Shared/ConduitLLM.Providers/DatabaseAwareLLMClientFactory.cs b/Shared/ConduitLLM.Providers/DatabaseAwareLLMClientFactory.cs index 0690f7f65..4941fcca5 100644 --- a/Shared/ConduitLLM.Providers/DatabaseAwareLLMClientFactory.cs +++ b/Shared/ConduitLLM.Providers/DatabaseAwareLLMClientFactory.cs @@ -5,14 +5,9 @@ using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Services; -using ConduitLLM.Providers.OpenAI; -using ConduitLLM.Providers.Groq; -using ConduitLLM.Providers.Replicate; -using ConduitLLM.Providers.Fireworks; -using ConduitLLM.Providers.MiniMax; -using ConduitLLM.Providers.Cerebras; -using ConduitLLM.Providers.SambaNova; -using ConduitLLM.Providers.DeepInfra; +using ConduitLLM.Providers.Configuration; + +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace ConduitLLM.Providers @@ -64,94 +59,49 @@ public DatabaseAwareLLMClientFactory( } /// - public ILLMClient GetClient(string modelName) + public async Task GetClientAsync(string modelName, CancellationToken cancellationToken = default) { - _logger.LogDebug("DatabaseAwareLLMClientFactory.GetClient called for model: {ModelName}", modelName); - + _logger.LogDebug("DatabaseAwareLLMClientFactory.GetClientAsync called for model: {ModelName}", modelName); + // Get model mapping from database - var mapping = Task.Run(async () => - await _mappingService.GetMappingByModelAliasAsync(modelName)).Result; - + var mapping = await _mappingService.GetMappingByModelAliasAsync(modelName); + if (mapping == null) { _logger.LogWarning("No model mapping found in database for alias: {ModelAlias}", modelName); throw new ModelNotFoundException(modelName, $"Model '{modelName}' not found. Please check your model configuration."); } - - _logger.LogDebug("Found mapping in database: {ModelAlias} -> ProviderId:{ProviderId}/{ProviderModelId}", + + _logger.LogDebug("Found mapping in database: {ModelAlias} -> ProviderId:{ProviderId}/{ProviderModelId}", mapping.ModelAlias, mapping.ProviderId, mapping.ProviderModelId); - + // Get the provider from database - var provider = Task.Run(async () => - await _credentialService.GetProviderByIdAsync(mapping.ProviderId)).Result; - + var provider = await _credentialService.GetProviderByIdAsync(mapping.ProviderId); + if (provider == null) { _logger.LogWarning("Provider {ProviderId} not found", mapping.ProviderId); throw new ServiceUnavailableException($"Provider for model '{modelName}' is not available.", "Provider"); } - - if (!provider.IsEnabled) - { - _logger.LogWarning("Provider {ProviderId} is disabled", mapping.ProviderId); - throw new ServiceUnavailableException($"Provider '{provider.ProviderName}' is currently disabled.", provider.ProviderName); - } - - // Get key credentials for this provider - var keyCredentials = Task.Run(async () => - await _credentialService.GetKeyCredentialsByProviderIdAsync(provider.Id)).Result; - - // Find the primary key or use the first enabled one - var primaryKey = keyCredentials.FirstOrDefault(k => k.IsPrimary && k.IsEnabled) - ?? keyCredentials.FirstOrDefault(k => k.IsEnabled); - - if (primaryKey == null) - { - _logger.LogWarning("No enabled API key found for provider {ProviderId}", provider.Id); - throw new ConfigurationException($"No API key configured for provider '{provider.ProviderName}'."); - } - - // Create the appropriate client based on provider type + + var primaryKey = await ValidateProviderAndGetCredentialAsync(provider); return CreateClientForProvider(provider, primaryKey, mapping.ProviderModelId); } - /// - public ILLMClient GetClientByProviderId(int providerId) + public async Task GetClientByProviderIdAsync(int providerId, CancellationToken cancellationToken = default) { _logger.LogDebug("Getting client for provider ID {ProviderId} using database credentials", providerId); - // Get provider from database - var provider = Task.Run(async () => - await _credentialService.GetProviderByIdAsync(providerId)).Result; + var provider = await _credentialService.GetProviderByIdAsync(providerId); if (provider == null) { _logger.LogWarning("No provider found for provider ID {ProviderId} in database", providerId); throw new InvalidRequestException($"Provider with ID '{providerId}' not found.", "provider_not_found", "providerId"); } - - if (!provider.IsEnabled) - { - _logger.LogWarning("Provider {ProviderId} is disabled", providerId); - throw new ServiceUnavailableException($"Provider '{provider.ProviderName}' is currently disabled.", provider.ProviderName); - } - - // Get key credentials for this provider - var keyCredentials = Task.Run(async () => - await _credentialService.GetKeyCredentialsByProviderIdAsync(provider.Id)).Result; - - // Find the primary key or use the first enabled one - var primaryKey = keyCredentials.FirstOrDefault(k => k.IsPrimary && k.IsEnabled) - ?? keyCredentials.FirstOrDefault(k => k.IsEnabled); - - if (primaryKey == null) - { - _logger.LogWarning("No enabled API key found for provider {ProviderId}", provider.Id); - throw new ConfigurationException($"No API key configured for provider '{provider.ProviderName}'."); - } - // Use a default model ID for operations that don't require a specific model + var primaryKey = await ValidateProviderAndGetCredentialAsync(provider); return CreateClientForProvider(provider, primaryKey, "default-model-id"); } @@ -164,44 +114,20 @@ public ILLMClient GetClientByProviderId(int providerId) } /// - public ILLMClient GetClientByProviderType(ProviderType providerType) + public async Task GetClientByProviderTypeAsync(ProviderType providerType, CancellationToken cancellationToken = default) { _logger.LogDebug("Getting client for provider type {ProviderType} using database credentials", providerType); - // Get first enabled provider of this type from database - var provider = Task.Run(async () => - { - var allProviders = await _credentialService.GetAllProvidersAsync(); - return allProviders.FirstOrDefault(p => p.ProviderType == providerType); - }).Result; + var allProviders = await _credentialService.GetAllProvidersAsync(); + var provider = allProviders.FirstOrDefault(p => p.ProviderType == providerType); if (provider == null) { _logger.LogWarning("No provider found for provider type {ProviderType} in database", providerType); throw new InvalidRequestException($"No provider configured for type '{providerType}'.", "provider_type_not_found", "providerType"); } - - if (!provider.IsEnabled) - { - _logger.LogWarning("Provider {ProviderId} of type {ProviderType} is disabled", provider.Id, providerType); - throw new ServiceUnavailableException($"Provider '{provider.ProviderName}' of type '{providerType}' is currently disabled.", provider.ProviderName); - } - - // Get key credentials for this provider - var keyCredentials = Task.Run(async () => - await _credentialService.GetKeyCredentialsByProviderIdAsync(provider.Id)).Result; - - // Find the primary key or use the first enabled one - var primaryKey = keyCredentials.FirstOrDefault(k => k.IsPrimary && k.IsEnabled) - ?? keyCredentials.FirstOrDefault(k => k.IsEnabled); - - if (primaryKey == null) - { - _logger.LogWarning("No enabled API key found for provider {ProviderId}", provider.Id); - throw new ConfigurationException($"No API key configured for provider '{provider.ProviderName}'."); - } - // Use a default model ID for operations that don't require a specific model + var primaryKey = await ValidateProviderAndGetCredentialAsync(provider); return CreateClientForProvider(provider, primaryKey, "default-model-id"); } @@ -231,83 +157,79 @@ public ILLMClient CreateTestClient(Provider provider, ProviderKeyCredential keyC return CreateClientForProvider(provider, keyCredential, testModelId); } + /// + /// Validates that a provider is enabled, then retrieves its primary key credential. + /// + private async Task ValidateProviderAndGetCredentialAsync(Provider provider) + { + if (!provider.IsEnabled) + { + _logger.LogWarning("Provider {ProviderId} is disabled", provider.Id); + throw new ServiceUnavailableException( + $"Provider '{provider.ProviderName}' is currently disabled.", provider.ProviderName); + } + + return await GetPrimaryKeyCredentialAsync(provider); + } + + private async Task GetPrimaryKeyCredentialAsync(Provider provider) + { + var keyCredentials = await _credentialService.GetKeyCredentialsByProviderIdAsync(provider.Id); + + var primaryKey = keyCredentials.FirstOrDefault(k => k.IsPrimary && k.IsEnabled) + ?? keyCredentials.FirstOrDefault(k => k.IsEnabled); + + if (primaryKey == null) + { + _logger.LogWarning("No enabled API key found for provider {ProviderId}", provider.Id); + throw new ConfigurationException($"No API key configured for provider '{provider.ProviderName}'."); + } + + return primaryKey; + } + private ILLMClient CreateClientForProvider(Provider provider, ProviderKeyCredential keyCredential, string modelId) { var providerName = provider.ProviderType.ToString().ToLowerInvariant(); - - _logger.LogDebug("Creating client for provider type: {ProviderType}, model: {ModelId}", + + _logger.LogDebug("Creating client for provider type: {ProviderType}, model: {ModelId}", provider.ProviderType, modelId); - // TODO: Get default models configuration from somewhere (database?) - ProviderDefaultModels? defaultModels = null; + // Create the client creation context with all dependencies + var context = new ClientCreationContext + { + LoggerFactory = _loggerFactory, + HttpClientFactory = _httpClientFactory, + CapabilityService = _capabilityService, + DefaultModels = null // TODO: Get default models configuration from somewhere (database?) + }; - // Create the base client + // Create the base client using the registry ILLMClient client; - - // Create clients using the provider type - switch (provider.ProviderType) + try + { + client = ClientCreatorRegistry.CreateClient( + provider.ProviderType, + provider, + keyCredential, + modelId, + context); + } + catch (ArgumentException ex) + { + throw new ConfigurationException($"Unsupported provider type: {provider.ProviderType}", ex); + } + + // Apply prompt caching decorator (before context/perf so it modifies request early) + var settingsService = _serviceProvider.GetService(); + if (settingsService != null) { - case ProviderType.OpenAI: - var openAiLogger = _loggerFactory.CreateLogger(); - client = new OpenAIClient(provider, keyCredential, modelId, openAiLogger, - _httpClientFactory, _capabilityService, defaultModels); - break; - - case ProviderType.Groq: - var groqLogger = _loggerFactory.CreateLogger(); - client = new GroqClient(provider, keyCredential, modelId, groqLogger, - _httpClientFactory, defaultModels); - break; - - case ProviderType.Replicate: - var replicateLogger = _loggerFactory.CreateLogger(); - client = new ReplicateClient(provider, keyCredential, modelId, replicateLogger, - _httpClientFactory, defaultModels); - break; - - case ProviderType.Fireworks: - var fireworksLogger = _loggerFactory.CreateLogger(); - client = new FireworksClient(provider, keyCredential, modelId, fireworksLogger, - _httpClientFactory, defaultModels); - break; - - case ProviderType.OpenAICompatible: - var compatibleLogger = _loggerFactory.CreateLogger(); - client = new OpenAICompatibleGenericClient(provider, keyCredential, modelId, compatibleLogger, - _httpClientFactory, defaultModels); - break; - - case ProviderType.MiniMax: - var miniMaxLogger = _loggerFactory.CreateLogger(); - client = new MiniMaxClient(provider, keyCredential, modelId, miniMaxLogger, - _httpClientFactory, defaultModels); - break; - - - case ProviderType.Cerebras: - var cerebrasLogger = _loggerFactory.CreateLogger(); - client = new CerebrasClient(provider, keyCredential, modelId, cerebrasLogger, - _httpClientFactory, defaultModels); - break; - - case ProviderType.SambaNova: - var sambaNovaLogger = _loggerFactory.CreateLogger(); - client = new SambaNovaClient(provider, keyCredential, modelId, sambaNovaLogger, - _httpClientFactory, defaultModels); - break; - - case ProviderType.DeepInfra: - var deepInfraLogger = _loggerFactory.CreateLogger(); - client = new DeepInfraClient(provider, keyCredential, modelId, deepInfraLogger, - _httpClientFactory, defaultModels); - break; - - default: - throw new ConfigurationException($"Unsupported provider type: {provider.ProviderType}"); + var cachingLogger = _loggerFactory.CreateLogger(); + client = new PromptCachingLLMClient(client, settingsService, cachingLogger); } // Apply context decorator to set provider key context for error tracking - _logger.LogDebug("Applying context decorator for KeyId: {KeyId}, ProviderId: {ProviderId}", + _logger.LogDebug("Applying context decorator for KeyId: {KeyId}, ProviderId: {ProviderId}", keyCredential.Id, provider.Id); client = new ContextAwareLLMClient(client, keyCredential.Id, provider.Id, _serviceProvider); diff --git a/Shared/ConduitLLM.Providers/Extensions/HttpClientExtensions.cs b/Shared/ConduitLLM.Providers/Extensions/HttpClientExtensions.cs index 10c63fb21..f35b88216 100644 --- a/Shared/ConduitLLM.Providers/Extensions/HttpClientExtensions.cs +++ b/Shared/ConduitLLM.Providers/Extensions/HttpClientExtensions.cs @@ -1,3 +1,4 @@ +using ConduitLLM.Core.Interfaces; using ConduitLLM.Providers.Configuration; using ConduitLLM.Providers.OpenAI; using ConduitLLM.Providers.Groq; @@ -31,84 +32,40 @@ public static IServiceCollection AddLLMProviderHttpClients(this IServiceCollecti // Register provider clients with timeout and retry policies services.AddHttpClient() - // --- Outer Policy: Timeout --- - .AddPolicyHandler((provider, _) => - { - var logger = provider.GetRequiredService>(); - var timeoutOptions = provider.GetService>()?.Value ?? new TimeoutOptions(); - return ResiliencePolicies.GetTimeoutPolicy( - TimeSpan.FromSeconds(timeoutOptions.TimeoutSeconds), - timeoutOptions.EnableTimeoutLogging ? logger : null); - }) - // --- Inner Policy: Retry with Error Tracking --- - .AddPolicyHandler((provider, _) => - { - var logger = provider.GetRequiredService>(); - var retryOptions = provider.GetService>()?.Value ?? new RetryOptions(); - - // Use error tracking retry policy if error tracking service is available - var errorTracker = provider.GetService(); - if (errorTracker != null) - { - return ResiliencePolicies.GetRetryPolicyWithErrorTracking( - provider, - retryOptions.MaxRetries, - TimeSpan.FromSeconds(retryOptions.InitialDelaySeconds), - TimeSpan.FromSeconds(retryOptions.MaxDelaySeconds)); - } - - // Fall back to standard retry policy if error tracking is not available - return ResiliencePolicies.GetRetryPolicy( - retryOptions.MaxRetries, - TimeSpan.FromSeconds(retryOptions.InitialDelaySeconds), - TimeSpan.FromSeconds(retryOptions.MaxDelaySeconds), - retryOptions.EnableRetryLogging ? logger : null); - }); - + .AddProviderResiliencePolicies(); services.AddHttpClient() - // --- Outer Policy: Timeout --- - .AddPolicyHandler((provider, _) => - { - var logger = provider.GetRequiredService>(); - var timeoutOptions = provider.GetService>()?.Value ?? new TimeoutOptions(); - return ResiliencePolicies.GetTimeoutPolicy( - TimeSpan.FromSeconds(timeoutOptions.TimeoutSeconds), - timeoutOptions.EnableTimeoutLogging ? logger : null); - }) - // --- Inner Policy: Retry with Error Tracking --- - .AddPolicyHandler((provider, _) => - { - var logger = provider.GetRequiredService>(); - var retryOptions = provider.GetService>()?.Value ?? new RetryOptions(); - - // Use error tracking retry policy if error tracking service is available - var errorTracker = provider.GetService(); - if (errorTracker != null) - { - return ResiliencePolicies.GetRetryPolicyWithErrorTracking( - provider, - retryOptions.MaxRetries, - TimeSpan.FromSeconds(retryOptions.InitialDelaySeconds), - TimeSpan.FromSeconds(retryOptions.MaxDelaySeconds)); - } - - // Fall back to standard retry policy if error tracking is not available - return ResiliencePolicies.GetRetryPolicy( - retryOptions.MaxRetries, - TimeSpan.FromSeconds(retryOptions.InitialDelaySeconds), - TimeSpan.FromSeconds(retryOptions.MaxDelaySeconds), - retryOptions.EnableRetryLogging ? logger : null); - }); + .AddProviderResiliencePolicies(); // Register MiniMaxClient with standard timeout/retry policies for non-video operations // This will be overridden by VideoHttpClientExtensions for video generation services.AddHttpClient("minimaxLLMClient") + .AddProviderResiliencePolicies(); + + // Note: Replicate, Fireworks, OpenAICompatible, Ultravox, ElevenLabs, and Cerebras + // clients will be registered here when their HttpClient implementations are available + + return services; + } + + /// + /// Adds timeout and retry resilience policies with optional error tracking to an HttpClient. + /// Uses configuration from TimeoutOptions and RetryOptions. + /// + /// The client type for logger categorization + /// The HttpClient builder + /// The HttpClient builder for chaining + public static IHttpClientBuilder AddProviderResiliencePolicies( + this IHttpClientBuilder builder) + where TClient : class + { + return builder // --- Outer Policy: Timeout --- .AddPolicyHandler((provider, _) => { - var logger = provider.GetService>(); - var timeoutOptions = provider.GetService>()?.Value ?? new TimeoutOptions(); + var logger = provider.GetService>(); + var timeoutOptions = provider.GetService>()?.Value + ?? new TimeoutOptions(); return ResiliencePolicies.GetTimeoutPolicy( TimeSpan.FromSeconds(timeoutOptions.TimeoutSeconds), timeoutOptions.EnableTimeoutLogging ? logger : null); @@ -116,11 +73,12 @@ public static IServiceCollection AddLLMProviderHttpClients(this IServiceCollecti // --- Inner Policy: Retry with Error Tracking --- .AddPolicyHandler((provider, _) => { - var logger = provider.GetService>(); - var retryOptions = provider.GetService>()?.Value ?? new RetryOptions(); - + var logger = provider.GetService>(); + var retryOptions = provider.GetService>()?.Value + ?? new RetryOptions(); + // Use error tracking retry policy if error tracking service is available - var errorTracker = provider.GetService(); + var errorTracker = provider.GetService(); if (errorTracker != null) { return ResiliencePolicies.GetRetryPolicyWithErrorTracking( @@ -129,7 +87,7 @@ public static IServiceCollection AddLLMProviderHttpClients(this IServiceCollecti TimeSpan.FromSeconds(retryOptions.InitialDelaySeconds), TimeSpan.FromSeconds(retryOptions.MaxDelaySeconds)); } - + // Fall back to standard retry policy if error tracking is not available return ResiliencePolicies.GetRetryPolicy( retryOptions.MaxRetries, @@ -137,10 +95,5 @@ public static IServiceCollection AddLLMProviderHttpClients(this IServiceCollecti TimeSpan.FromSeconds(retryOptions.MaxDelaySeconds), retryOptions.EnableRetryLogging ? logger : null); }); - - // Note: Replicate, Fireworks, OpenAICompatible, Ultravox, ElevenLabs, and Cerebras - // clients will be registered here when their HttpClient implementations are available - - return services; } } diff --git a/Shared/ConduitLLM.Providers/Helpers/AsyncJobPoller.cs b/Shared/ConduitLLM.Providers/Helpers/AsyncJobPoller.cs new file mode 100644 index 000000000..fbb831481 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Helpers/AsyncJobPoller.cs @@ -0,0 +1,292 @@ +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Metrics; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Providers.Helpers +{ + /// + /// Classification of an async job's current state as determined by the caller's status classifier. + /// + public enum JobState + { + /// Still running. Wait and poll again with normal backoff. + InProgress, + + /// Completed successfully. Poller returns the extracted result. + Succeeded, + + /// Terminally failed. Poller throws the exception from extractFailure. + Failed, + + /// Rate-limited. Wait and poll again, forcing delay to . + RateLimited, + + /// Transient error (e.g. upstream 5xx, in-response system error). Counts toward . + TransientError, + } + + /// Backoff strategy between poll attempts. + public enum BackoffStrategy + { + /// Use between every poll. + Fixed, + + /// Grow the delay by each attempt with jitter, capped at . + ExponentialWithJitter, + } + + /// Options controlling polling cadence, timeout, and transient-error tolerance. + /// Delay before the first retry (and every retry when ). + /// Upper bound on per-attempt delay. Also used when is reported. + /// Maximum total polling duration. Exceeding this throws . + /// Backoff strategy. + /// + /// Maximum consecutive transient failures (network errors, ) before giving up. + /// When null, any transient failure aborts immediately (fail-fast). + /// + /// Multiplier applied per attempt under . + /// Maximum random jitter added per attempt under . + /// Emit an informational heartbeat log every N attempts when the status is unchanged. + public sealed record PollingOptions( + TimeSpan InitialDelay, + TimeSpan MaxDelay, + TimeSpan Timeout, + BackoffStrategy Backoff = BackoffStrategy.Fixed, + int? MaxConsecutiveTransientErrors = null, + double BackoffMultiplier = 1.5, + int JitterMilliseconds = 500, + int HeartbeatLogEveryNAttempts = 15); + + /// Progress report passed to the onProgress callback of . + public sealed record PollProgress(int AttemptCount, TimeSpan Elapsed, JobState State, string? StatusText); + + /// + /// Provider-agnostic helper for polling a long-running async job (submit โ†’ poll โ†’ extract). + /// Used by providers like Replicate (predictions) and MiniMax (video generation) that follow + /// the same loop structure but differ in backoff, status shapes, and failure classification. + /// + public static class AsyncJobPoller + { + /// + /// Polls until the classifier reports success, failure, or the timeout elapses. + /// + /// Provider-specific status response type. + /// Return type on success. + /// Fetches the current status. May throw transient exceptions (e.g. ); these count toward the consecutive-error budget. + /// Maps a status response to a . + /// Invoked once when returns . Its return value is the final result. + /// Invoked once when returns . The returned exception is thrown. + /// Polling cadence + tolerance. + /// Logger for status-change and heartbeat messages. + /// Cancellation token. On cancel, runs (best-effort) and is thrown. + /// Optional per-attempt callback (e.g., for SignalR progress emission). Exceptions from the callback are logged but swallowed. + /// Optional best-effort remote-cancel callback, invoked on timeout or caller cancellation. Exceptions are logged but swallowed. + /// Short label used in log/exception messages (e.g. "Replicate prediction", "MiniMax video"). + /// Delay implementation. Defaults to . Tests may inject a no-op. + /// Optional polling scope to record attempt count, transient errors, and terminal outcome. Caller owns disposal. + public static async Task PollAsync( + Func> fetchStatus, + Func classify, + Func extractSuccess, + Func extractFailure, + PollingOptions options, + ILogger logger, + CancellationToken cancellationToken, + Func? onProgress = null, + Func? onAbort = null, + string operationName = "async job", + Func? delayFunc = null, + ProviderInstrumentation.PollingScope? instrumentation = null) + { + ArgumentNullException.ThrowIfNull(fetchStatus); + ArgumentNullException.ThrowIfNull(classify); + ArgumentNullException.ThrowIfNull(extractSuccess); + ArgumentNullException.ThrowIfNull(extractFailure); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + var delay = delayFunc ?? Task.Delay; + var start = DateTime.UtcNow; + var currentDelay = options.InitialDelay; + var attempt = 0; + var consecutiveTransient = 0; + JobState? lastState = null; + + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + var elapsed = DateTime.UtcNow - start; + logger.LogWarning("{Operation} polling canceled after {Elapsed:F1}s and {Attempts} attempts", + operationName, elapsed.TotalSeconds, attempt); + instrumentation?.RecordCancelled(); + await SafeAbortAsync(onAbort, logger, operationName); + throw new OperationCanceledException($"{operationName} polling was canceled", cancellationToken); + } + + var totalElapsed = DateTime.UtcNow - start; + if (totalElapsed > options.Timeout) + { + logger.LogError("{Operation} timed out after {Elapsed:F1}s and {Attempts} attempts (last state: {LastState})", + operationName, totalElapsed.TotalSeconds, attempt, lastState?.ToString() ?? "none"); + instrumentation?.RecordTimeout(); + await SafeAbortAsync(onAbort, logger, operationName); + throw new RequestTimeoutException( + $"{operationName} timed out after {options.Timeout.TotalSeconds:F0}s (last state: {lastState?.ToString() ?? "unknown"})", + (int)options.Timeout.TotalSeconds, + operationName); + } + + attempt++; + + TStatus status; + try + { + status = await fetchStatus(cancellationToken); + } + catch (OperationCanceledException) + { + instrumentation?.RecordCancelled(); + throw; + } + catch (ConduitException ex) + { + // Caller-classified domain exceptions propagate immediately. + instrumentation?.RecordFailure(ex.GetType().Name); + throw; + } + catch (Exception ex) + { + consecutiveTransient++; + instrumentation?.RecordAttempt(state: null); + instrumentation?.RecordTransientError(ex.GetType().Name); + logger.LogWarning(ex, + "{Operation} transient fetch error on attempt {Attempt} (consecutive: {Count})", + operationName, attempt, consecutiveTransient); + + if (options.MaxConsecutiveTransientErrors is null) + { + instrumentation?.RecordFailure(ex.GetType().Name); + throw new LLMCommunicationException( + $"{operationName} fetch failed: {ex.Message}", ex); + } + if (consecutiveTransient >= options.MaxConsecutiveTransientErrors.Value) + { + instrumentation?.RecordFailure(ex.GetType().Name); + throw new LLMCommunicationException( + $"{operationName} failed after {consecutiveTransient} consecutive transient errors: {ex.Message}", ex); + } + + await delay(options.MaxDelay, cancellationToken); + continue; + } + + var state = classify(status); + instrumentation?.RecordAttempt(state.ToString()); + + if (state != JobState.TransientError) + { + consecutiveTransient = 0; + } + + if (state != lastState) + { + logger.LogInformation("{Operation} state {Old} โ†’ {New} on attempt {Attempt} ({Elapsed:F1}s)", + operationName, lastState?.ToString() ?? "initial", state, attempt, totalElapsed.TotalSeconds); + lastState = state; + } + else if (attempt % options.HeartbeatLogEveryNAttempts == 0) + { + logger.LogInformation("{Operation} still {State} after {Attempts} attempts ({Elapsed:F1}s)", + operationName, state, attempt, totalElapsed.TotalSeconds); + } + + if (onProgress is not null) + { + try + { + await onProgress(new PollProgress(attempt, totalElapsed, state, state.ToString()), status); + } + catch (Exception ex) + { + logger.LogWarning(ex, "{Operation} onProgress callback threw", operationName); + } + } + + switch (state) + { + case JobState.Succeeded: + logger.LogInformation("{Operation} succeeded after {Attempts} attempts ({Elapsed:F1}s)", + operationName, attempt, totalElapsed.TotalSeconds); + return extractSuccess(status); + + case JobState.Failed: + { + var failure = extractFailure(status); + instrumentation?.RecordFailure(failure.GetType().Name); + throw failure; + } + + case JobState.RateLimited: + await delay(options.MaxDelay, cancellationToken); + continue; + + case JobState.TransientError: + consecutiveTransient++; + instrumentation?.RecordTransientError(nameof(JobState.TransientError)); + if (options.MaxConsecutiveTransientErrors is null) + { + instrumentation?.RecordFailure(nameof(JobState.TransientError)); + throw new LLMCommunicationException( + $"{operationName} reported a transient error and fail-fast is enabled"); + } + if (consecutiveTransient >= options.MaxConsecutiveTransientErrors.Value) + { + instrumentation?.RecordFailure(nameof(JobState.TransientError)); + throw new LLMCommunicationException( + $"{operationName} failed after {consecutiveTransient} consecutive transient errors"); + } + await delay(options.MaxDelay, cancellationToken); + continue; + + case JobState.InProgress: + default: + await delay(currentDelay, cancellationToken); + currentDelay = NextDelay(currentDelay, options); + continue; + } + } + } + + private static TimeSpan NextDelay(TimeSpan current, PollingOptions options) + { + if (options.Backoff == BackoffStrategy.Fixed) + { + return options.InitialDelay; + } + + var jitter = options.JitterMilliseconds > 0 + ? TimeSpan.FromMilliseconds(Random.Shared.Next(options.JitterMilliseconds)) + : TimeSpan.Zero; + var next = TimeSpan.FromMilliseconds(current.TotalMilliseconds * options.BackoffMultiplier) + jitter; + return next > options.MaxDelay ? options.MaxDelay : next; + } + + private static async Task SafeAbortAsync(Func? onAbort, ILogger logger, string operationName) + { + if (onAbort is null) + { + return; + } + try + { + await onAbort(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "{Operation} onAbort callback threw", operationName); + } + } + } +} diff --git a/Shared/ConduitLLM.Providers/Helpers/AwsSignatureV4.cs b/Shared/ConduitLLM.Providers/Helpers/AwsSignatureV4.cs index b459f0cbf..33141b3c9 100644 --- a/Shared/ConduitLLM.Providers/Helpers/AwsSignatureV4.cs +++ b/Shared/ConduitLLM.Providers/Helpers/AwsSignatureV4.cs @@ -13,8 +13,36 @@ public static class AwsSignatureV4 private const string DateFormat = "yyyyMMdd"; private const string DateTimeFormat = "yyyyMMddTHHmmssZ"; + /// + /// Signs an HTTP request with AWS Signature V4 asynchronously. + /// + /// The HTTP request to sign. + /// AWS Access Key ID. + /// AWS Secret Access Key. + /// AWS region (e.g., "us-east-1"). + /// AWS service name (e.g., "bedrock"). + public static async Task SignRequestAsync(HttpRequestMessage request, string accessKey, string secretKey, string region, string service) + { + var now = DateTime.UtcNow; + var dateStamp = now.ToString(DateFormat, CultureInfo.InvariantCulture); + var dateTimeStamp = now.ToString(DateTimeFormat, CultureInfo.InvariantCulture); + + // Add required headers + request.Headers.Add("X-Amz-Date", dateTimeStamp); + + // Get the request body + string bodyContent = ""; + if (request.Content != null) + { + bodyContent = await request.Content.ReadAsStringAsync(); + } + + SignRequestInternal(request, accessKey, secretKey, region, service, bodyContent, dateTimeStamp, dateStamp); + } + /// /// Signs an HTTP request with AWS Signature V4. + /// For better performance in async contexts, prefer using SignRequestAsync. /// /// The HTTP request to sign. /// AWS Access Key ID. @@ -26,35 +54,49 @@ public static void SignRequest(HttpRequestMessage request, string accessKey, str var now = DateTime.UtcNow; var dateStamp = now.ToString(DateFormat, CultureInfo.InvariantCulture); var dateTimeStamp = now.ToString(DateTimeFormat, CultureInfo.InvariantCulture); - + // Add required headers request.Headers.Add("X-Amz-Date", dateTimeStamp); - - // Get the request body + + // Get the request body - use synchronous read for compatibility string bodyContent = ""; if (request.Content != null) { - bodyContent = request.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + // Read content synchronously - prefer SignRequestAsync for async contexts + bodyContent = request.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } - + + SignRequestInternal(request, accessKey, secretKey, region, service, bodyContent, dateTimeStamp, dateStamp); + } + + private static void SignRequestInternal( + HttpRequestMessage request, + string accessKey, + string secretKey, + string region, + string service, + string bodyContent, + string dateTimeStamp, + string dateStamp) + { // Create canonical request var canonicalRequest = CreateCanonicalRequest(request, bodyContent); - + // Create string to sign var stringToSign = CreateStringToSign(canonicalRequest, dateTimeStamp, dateStamp, region, service); - + // Calculate signature var signature = CalculateSignature(stringToSign, secretKey, dateStamp, region, service); - + // Create authorization header var authorizationHeader = CreateAuthorizationHeader( - accessKey, - signature, - dateStamp, - region, - service, + accessKey, + signature, + dateStamp, + region, + service, GetSignedHeaders(request)); - + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(Algorithm, authorizationHeader); } diff --git a/Shared/ConduitLLM.Providers/Helpers/ContentHelper.cs b/Shared/ConduitLLM.Providers/Helpers/ContentHelper.cs index 38711a7c0..07e799a79 100644 --- a/Shared/ConduitLLM.Providers/Helpers/ContentHelper.cs +++ b/Shared/ConduitLLM.Providers/Helpers/ContentHelper.cs @@ -66,7 +66,7 @@ public static List ExtractMultimodalContent(object? content) } } - if (textParts.Count() > 0) + if (textParts.Any()) { return textParts; } @@ -288,6 +288,74 @@ public static bool IsTextOnly(object? content) } } + /// + /// Determines if the content should be preserved as a JSON array rather than collapsed to a string. + /// Returns true if any content block has structured metadata like cache_control, + /// even if the content is otherwise text-only. + /// + /// The message content + /// True if the content has structured metadata that would be lost by collapsing to a string + public static bool ShouldPreserveAsArray(object? content) + { + if (content == null || content is string) + return false; + + // Handle JSON Element + if (content is JsonElement jsonElement) + { + if (jsonElement.ValueKind != JsonValueKind.Array) + return false; + + foreach (var element in jsonElement.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object) + continue; + + // Check for cache_control or other structured metadata beyond type/text/image_url + if (element.TryGetProperty("cache_control", out _)) + return true; + } + + return false; + } + + // Handle IEnumerable of dictionaries (from PromptCacheInjectionService) + if (content is IEnumerable contentList) + { + foreach (var part in contentList) + { + if (part is IDictionary dict && dict.ContainsKey("cache_control")) + return true; + if (part is IDictionary dictNonNull && dictNonNull.ContainsKey("cache_control")) + return true; + } + return false; + } + + // Try to serialize and check + try + { + var json = JsonSerializer.Serialize(content); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var element in root.EnumerateArray()) + { + if (element.TryGetProperty("cache_control", out _)) + return true; + } + } + } + catch + { + // If we can't process it, no structured metadata + } + + return false; + } + /// /// Extracts image URLs from multimodal content. /// @@ -314,7 +382,7 @@ public static List ExtractImageUrls(object? content) } } - if (imageUrls.Count() > 0) + if (imageUrls.Any()) { return imageUrls; } @@ -403,13 +471,13 @@ public static string DescribeContent(object? content) var sb = new StringBuilder(); - if (textParts.Count() > 0) + if (textParts.Any()) { var combinedText = string.Join(" ", textParts); sb.Append($"Text parts: {textParts.Count} ({(combinedText.Length > 50 ? combinedText.Substring(0, 47) + "..." : combinedText)})"); } - if (imageUrls.Count() > 0) + if (imageUrls.Any()) { if (sb.Length > 0) sb.Append(", "); diff --git a/Shared/ConduitLLM.Providers/Helpers/DateTimeExtensions.cs b/Shared/ConduitLLM.Providers/Helpers/DateTimeExtensions.cs deleted file mode 100644 index e59265ee7..000000000 --- a/Shared/ConduitLLM.Providers/Helpers/DateTimeExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace ConduitLLM.Providers.Helpers -{ - /// - /// Extensions for DateTime to provide Unix timestamp functionality. - /// - public static class DateTimeExtensions - { - private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - /// - /// Converts a DateTime to Unix timestamp (seconds since Unix epoch). - /// - /// The DateTime to convert. - /// Number of seconds since January 1, 1970, 00:00:00 UTC. - public static long ToUnixTimeSeconds(this DateTime dateTime) - { - return (long)(dateTime.ToUniversalTime() - UnixEpoch).TotalSeconds; - } - - /// - /// Converts a DateTime to Unix timestamp (milliseconds since Unix epoch). - /// - /// The DateTime to convert. - /// Number of milliseconds since January 1, 1970, 00:00:00 UTC. - public static long ToUnixTimeMilliseconds(this DateTime dateTime) - { - return (long)(dateTime.ToUniversalTime() - UnixEpoch).TotalMilliseconds; - } - } -} diff --git a/Shared/ConduitLLM.Providers/Helpers/JsonElementConverter.cs b/Shared/ConduitLLM.Providers/Helpers/JsonElementConverter.cs new file mode 100644 index 000000000..24c4245d9 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Helpers/JsonElementConverter.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace ConduitLLM.Providers.Helpers +{ + /// + /// Utility class for converting values to their corresponding .NET types. + /// + internal static class JsonElementConverter + { + /// + /// Converts a JsonElement to its actual .NET value for proper serialization. + /// + /// The JsonElement to convert. + /// The converted value as a proper .NET type. + internal static object? ConvertJsonElement(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + if (element.TryGetInt32(out var intValue)) + return intValue; + if (element.TryGetInt64(out var longValue)) + return longValue; + return element.GetDouble(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + return null; + case JsonValueKind.Array: + return element.EnumerateArray() + .Select(e => ConvertJsonElement(e)) + .ToList(); + case JsonValueKind.Object: + var dict = new Dictionary(); + foreach (var property in element.EnumerateObject()) + { + dict[property.Name] = ConvertJsonElement(property.Value); + } + return dict; + default: + return element.ToString(); + } + } + } +} diff --git a/Shared/ConduitLLM.Providers/Helpers/HttpClientHelper.cs b/Shared/ConduitLLM.Providers/Helpers/ProviderHttpHelper.cs similarity index 68% rename from Shared/ConduitLLM.Providers/Helpers/HttpClientHelper.cs rename to Shared/ConduitLLM.Providers/Helpers/ProviderHttpHelper.cs index a91d0f323..036829cd0 100644 --- a/Shared/ConduitLLM.Providers/Helpers/HttpClientHelper.cs +++ b/Shared/ConduitLLM.Providers/Helpers/ProviderHttpHelper.cs @@ -8,61 +8,21 @@ namespace ConduitLLM.Providers.Helpers { /// - /// Provider-specific extension of the core HttpClientHelper with additional methods - /// tailored for LLM API interactions. + /// Provider-specific HTTP utilities that extend the core HttpClientHelper functionality. + /// Provides specialized methods for LLM provider API interactions that are not covered + /// by the core HTTP helpers. /// /// /// - /// This class builds on the core HttpClientHelper functionality and adds specialized methods - /// for working with LLM provider APIs. It provides standardized approaches for handling - /// provider-specific request formatting, authentication schemes, and response parsing. + /// This class provides additional HTTP utilities specific to LLM provider needs, such as + /// form-encoded requests for authentication endpoints and multipart content for file uploads. /// /// - /// The helpers encapsulate common patterns used across different LLM clients to reduce - /// code duplication and ensure consistent error handling and logging. + /// For standard JSON requests and streaming, use directly. /// /// - public static class HttpClientHelper + public static class ProviderHttpHelper { - private static readonly JsonSerializerOptions DefaultJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - - /// - /// Sends a JSON request to an LLM provider API and deserializes the response. - /// - /// The type of the request object to serialize. - /// The type to deserialize the response into. - /// The HttpClient to use for the request. - /// The HTTP method to use. - /// The endpoint to send the request to. - /// The data to serialize and send. - /// Optional additional headers to include with the request. - /// Optional JSON serialization options. - /// Optional logger for request/response logging. - /// A token to monitor for cancellation requests. - /// The deserialized response object. - /// Thrown when there is an error communicating with the API. - /// - /// This method delegates to the core HttpClientHelper.SendJsonRequestAsync method - /// to maintain a consistent approach to HTTP requests across the application. - /// - public static Task SendJsonRequestAsync( - HttpClient client, - HttpMethod method, - string endpoint, - TRequest requestData, - IDictionary? headers = null, - JsonSerializerOptions? jsonOptions = null, - ILogger? logger = null, - CancellationToken cancellationToken = default) - { - return Core.Utilities.HttpClientHelper.SendJsonRequestAsync( - client, method, endpoint, requestData, headers, jsonOptions, logger, cancellationToken); - } - /// /// Sends a request with form URL encoded content and deserializes the response. /// @@ -91,14 +51,19 @@ public static async Task SendFormRequestAsync( ILogger? logger = null, CancellationToken cancellationToken = default) { - var options = jsonOptions ?? DefaultJsonOptions; + // Use Core's default options if none specified + var options = jsonOptions ?? new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; try { var request = new HttpRequestMessage(method, endpoint); // Add form content - if (formData != null && formData.Count() > 0) + if (formData != null && formData.Any()) { request.Content = new FormUrlEncodedContent(formData); } @@ -159,37 +124,6 @@ public static async Task SendFormRequestAsync( } } - /// - /// Sends a streaming request and returns the response for processing. - /// - /// The HttpClient to use for the request. - /// The HTTP method to use. - /// The endpoint to send the request to. - /// The data to serialize and send. - /// Optional additional headers to include with the request. - /// Optional JSON serialization options. - /// Optional logger for request/response logging. - /// A token to monitor for cancellation requests. - /// The HttpResponseMessage for further processing. - /// Thrown when there is an error communicating with the API. - /// - /// This method delegates to the core HttpClientHelper.SendStreamingRequestAsync method - /// to maintain a consistent approach to streaming requests across the application. - /// - public static Task SendStreamingRequestAsync( - HttpClient client, - HttpMethod method, - string endpoint, - TRequest requestData, - IDictionary? headers = null, - JsonSerializerOptions? jsonOptions = null, - ILogger? logger = null, - CancellationToken cancellationToken = default) - { - return Core.Utilities.HttpClientHelper.SendStreamingRequestAsync( - client, method, endpoint, requestData, headers, jsonOptions, logger, cancellationToken); - } - /// /// Formats query parameters for inclusion in a URL. /// @@ -201,7 +135,7 @@ public static Task SendStreamingRequestAsync( /// public static string FormatQueryParameters(Dictionary parameters) { - if (parameters == null || parameters.Count() == 0) + if (parameters == null || !parameters.Any()) { return string.Empty; } @@ -216,7 +150,7 @@ public static string FormatQueryParameters(Dictionary parameter } } - return queryParts.Count() > 0 ? "?" + string.Join("&", queryParts) : string.Empty; + return queryParts.Any() ? "?" + string.Join("&", queryParts) : string.Empty; } /// @@ -231,7 +165,7 @@ public static string FormatQueryParameters(Dictionary parameter /// public static string AppendQueryParameters(string baseUrl, Dictionary parameters) { - if (parameters == null || parameters.Count() == 0) + if (parameters == null || !parameters.Any()) { return baseUrl; } @@ -247,7 +181,7 @@ public static string AppendQueryParameters(string baseUrl, Dictionary 0 + return queryParts.Any() ? baseUrl + separator + string.Join("&", queryParts) : baseUrl; } diff --git a/Shared/ConduitLLM.Providers/ModelListService.cs b/Shared/ConduitLLM.Providers/ModelListService.cs index 231762f8a..ae17f11df 100644 --- a/Shared/ConduitLLM.Providers/ModelListService.cs +++ b/Shared/ConduitLLM.Providers/ModelListService.cs @@ -76,7 +76,7 @@ public async Task> GetModelsForProviderAsync( provider.ProviderName, provider.Id); // Create a client using the provider ID - var client = _clientFactory.GetClientByProviderId(provider.Id); + var client = await _clientFactory.GetClientByProviderIdAsync(provider.Id, cancellationToken); // Get models from the provider API var models = await client.ListModelsAsync( diff --git a/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.ErrorHandling.cs b/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.ErrorHandling.cs deleted file mode 100644 index e936df87e..000000000 --- a/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.ErrorHandling.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Text.Json; - -using ConduitLLM.Core.Exceptions; - -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Providers.Cerebras -{ - /// - /// CerebrasClient partial class containing error handling methods. - /// - public partial class CerebrasClient - { - /// - /// Processes HTTP errors and converts them to appropriate exceptions. - /// - /// The HTTP status code. - /// The response content. - /// Optional request ID for tracking. - /// An appropriate exception for the error. - private Exception ProcessHttpError(System.Net.HttpStatusCode statusCode, string responseContent, string? requestId = null) - { - Logger.LogError("Cerebras API error - Status: {StatusCode}, Content: {Content}, RequestId: {RequestId}", - statusCode, responseContent, requestId); - - return statusCode switch - { - System.Net.HttpStatusCode.Unauthorized => new ConfigurationException(Constants.ErrorMessages.InvalidApiKey), - System.Net.HttpStatusCode.TooManyRequests => new LLMCommunicationException(Constants.ErrorMessages.RateLimitExceeded), - System.Net.HttpStatusCode.NotFound => new ModelUnavailableException(Constants.ErrorMessages.ModelNotFound), - System.Net.HttpStatusCode.PaymentRequired => new LLMCommunicationException(Constants.ErrorMessages.QuotaExceeded), - System.Net.HttpStatusCode.BadRequest => ParseBadRequestError(responseContent), - System.Net.HttpStatusCode.InternalServerError => new LLMCommunicationException($"Cerebras API internal error: {responseContent}"), - System.Net.HttpStatusCode.ServiceUnavailable => new LLMCommunicationException("Cerebras API is temporarily unavailable. Please try again later."), - _ => new LLMCommunicationException($"Cerebras API error ({statusCode}): {responseContent}") - }; - } - - /// - /// Parses bad request errors to provide more specific error information. - /// - /// The response content containing error details. - /// An appropriate exception for the bad request error. - private Exception ParseBadRequestError(string responseContent) - { - try - { - using var document = JsonDocument.Parse(responseContent); - if (document.RootElement.TryGetProperty("error", out var errorElement)) - { - if (errorElement.TryGetProperty("message", out var messageElement)) - { - var errorMessage = messageElement.GetString(); - - // Check for specific error patterns - if (errorMessage?.Contains("model", StringComparison.OrdinalIgnoreCase) == true) - { - return new ModelUnavailableException($"Model error: {errorMessage}"); - } - - if (errorMessage?.Contains("token", StringComparison.OrdinalIgnoreCase) == true) - { - return new ValidationException($"Token limit error: {errorMessage}"); - } - - return new ValidationException($"Request error: {errorMessage}"); - } - } - } - catch (JsonException) - { - // Fall through to generic error if JSON parsing fails - } - - return new ValidationException($"Bad request: {responseContent}"); - } - } -} diff --git a/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.Validation.cs b/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.Validation.cs deleted file mode 100644 index 7d6205d34..000000000 --- a/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.Validation.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace ConduitLLM.Providers.Cerebras -{ - /// - /// CerebrasClient partial class containing validation methods. - /// - public partial class CerebrasClient - { - /// - /// Validates the model ID for Cerebras-specific requirements. - /// - /// The model ID to validate. - /// True if the model ID is valid, false otherwise. - private bool IsValidModelId(string modelId) - { - if (string.IsNullOrWhiteSpace(modelId)) - return false; - - // Cerebras model IDs follow specific patterns - var validPrefixes = new[] - { - "llama3.1-", - "llama-3.3-", - "llama-4-scout-", - "qwen-3-", - "deepseek-r1-" - }; - - foreach (var prefix in validPrefixes) - { - if (modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } - } -} diff --git a/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.cs b/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.cs index 33ad6c9d7..575179162 100644 --- a/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/Cerebras/CerebrasClient.cs @@ -2,6 +2,7 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Exceptions; using ConduitLLM.Providers.Common.Models; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; @@ -29,40 +30,11 @@ namespace ConduitLLM.Providers.Cerebras /// public partial class CerebrasClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient { - // API configuration constants - private static class Constants - { - public static class Urls - { - /// - /// Default base URL for the Cerebras Inference API - /// - public const string DefaultBaseUrl = "https://api.cerebras.ai/v1"; - } - - public static class Headers - { - /// - /// Authorization header for API key authentication - /// - public const string Authorization = "Authorization"; - } - - public static class Endpoints - { - public const string ChatCompletions = "/chat/completions"; - public const string Models = "/models"; - } - - public static class ErrorMessages - { - public const string MissingApiKey = "API key is missing for provider 'cerebras'"; - public const string RateLimitExceeded = "Cerebras API rate limit exceeded. Please try again later or reduce your request frequency."; - public const string InvalidApiKey = "Invalid Cerebras API key. Please check your credentials."; - public const string ModelNotFound = "The specified model is not available. Please check the model name and try again."; - public const string QuotaExceeded = "API quota exceeded. Please check your usage limits or upgrade your plan."; - } - } + /// + /// Gets the Cerebras-specific error messages from the configuration registry. + /// + private static ProviderErrorMessages CerebrasErrorMessages => + ProviderConfigurationRegistry.GetErrorMessages(ProviderType.Cerebras); /// /// Fallback models for Cerebras when the models endpoint is not available @@ -72,17 +44,17 @@ public static class ErrorMessages // Llama 3.1 models ExtendedModelInfo.Create("llama3.1-8b", "cerebras", "Llama 3.1 8B"), ExtendedModelInfo.Create("llama3.1-70b", "cerebras", "Llama 3.1 70B"), - + // Llama 3.3 models ExtendedModelInfo.Create("llama-3.3-70b", "cerebras", "Llama 3.3 70B"), - + // Llama 4 Scout models ExtendedModelInfo.Create("llama-4-scout-17b-16e-instruct", "cerebras", "Llama 4 Scout 17B Instruct"), - + // Qwen 3 models ExtendedModelInfo.Create("qwen-3-32b", "cerebras", "Qwen 3 32B"), ExtendedModelInfo.Create("qwen-3-235b-a22b", "cerebras", "Qwen 3 235B"), - + // DeepSeek models (private preview) ExtendedModelInfo.Create("deepseek-r1-distill-llama-70b", "cerebras", "DeepSeek R1 Distill Llama 70B") }; @@ -90,12 +62,12 @@ public static class ErrorMessages /// /// Initializes a new instance of the CerebrasClient class. /// - /// LLMProvider credentials containing API key and endpoint configuration. + /// The provider configuration. + /// The API key credential. /// The specific model ID to use with this provider. /// Logger for recording diagnostic information. /// Factory for creating HttpClient instances with proper configuration. /// Optional default model configuration for the provider. - /// Optional provider name override. If not specified, defaults to "cerebras". /// Thrown when any required parameter is null. /// Thrown when API key is missing. public CerebrasClient( @@ -104,21 +76,20 @@ public CerebrasClient( string providerModelId, ILogger logger, IHttpClientFactory httpClientFactory, - ProviderDefaultModels? defaultModels = null, - string? providerName = null) + ProviderDefaultModels? defaultModels = null) : base( provider, keyCredential, providerModelId, logger, httpClientFactory, - providerName ?? "cerebras", - baseUrl: Constants.Urls.DefaultBaseUrl, + "cerebras", + baseUrl: ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.Cerebras), defaultModels: defaultModels) { if (string.IsNullOrWhiteSpace(keyCredential.ApiKey)) { - throw new ConfigurationException(Constants.ErrorMessages.MissingApiKey); + throw new ConfigurationException(CerebrasErrorMessages.MissingApiKey); } } diff --git a/Shared/ConduitLLM.Providers/Providers/Cloudflare/CloudflareClient.Images.cs b/Shared/ConduitLLM.Providers/Providers/Cloudflare/CloudflareClient.Images.cs new file mode 100644 index 000000000..4f82f48a2 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Providers/Cloudflare/CloudflareClient.Images.cs @@ -0,0 +1,384 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Models; +using ConduitLLM.Providers.Helpers; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Providers.Cloudflare +{ + /// + /// CloudflareClient partial class containing image generation functionality. + /// + /// + /// + /// Cloudflare Workers AI image generation uses the native /ai/run/{MODEL_ID} endpoint, + /// which is NOT OpenAI-compatible. This override handles the Cloudflare-specific request + /// and response formats. + /// + /// + /// Response formats vary by model family: + /// - SD family, DreamShaper, Phoenix: Raw binary image (PNG or JPEG) + /// - Flux 1, Lucid Origin: JSON with base64-encoded image + /// + /// + /// Phase 1 supports JSON-body models only. Flux 2 models (which require multipart + /// form-data) are not yet supported. + /// + /// + public partial class CloudflareClient + { + /// + /// Maximum number of images that can be generated in a single request. + /// Cloudflare generates one image per API call, so N>1 requires parallel calls. + /// + private const int MaxImagesPerRequest = 4; + + /// + public override async Task CreateImageAsync( + ImageGenerationRequest request, + string? apiKey = null, + CancellationToken cancellationToken = default) + { + ValidateRequest(request, "CreateImage"); + + var modelId = request.Model ?? ProviderModelId; + var modelFamily = ClassifyModelFamily(modelId); + + if (modelFamily == CloudflareImageModelFamily.Flux2) + { + throw new NotSupportedException( + $"Flux 2 models require multipart form-data and are not yet supported. " + + $"Use Flux 1 Schnell (@cf/black-forest-labs/flux-1-schnell) or Stable Diffusion models instead."); + } + + return await ExecuteApiRequestAsync(async () => + { + var imageCount = Math.Min(Math.Max(request.N, 1), MaxImagesPerRequest); + + if (imageCount == 1) + { + var imageData = await GenerateSingleImageAsync(request, modelId, modelFamily, apiKey, cancellationToken); + return new ImageGenerationResponse + { + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Data = new List { imageData } + }; + } + + // Cloudflare generates one image per call; run N calls in parallel + var tasks = Enumerable.Range(0, imageCount) + .Select(_ => GenerateSingleImageAsync(request, modelId, modelFamily, apiKey, cancellationToken)) + .ToList(); + + var results = await Task.WhenAll(tasks); + + return new ImageGenerationResponse + { + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Data = results.ToList() + }; + }, "CreateImage", cancellationToken); + } + + /// + /// Generates a single image from Cloudflare Workers AI. + /// + private async Task GenerateSingleImageAsync( + ImageGenerationRequest request, + string modelId, + CloudflareImageModelFamily modelFamily, + string? apiKey, + CancellationToken cancellationToken) + { + using var client = CreateHttpClient(apiKey); + + var nativeEndpoint = BuildNativeEndpoint(modelId); + var requestBody = BuildImageRequestBody(request, modelFamily); + + Logger.LogInformation( + "Creating image using Cloudflare Workers AI at {Endpoint} with model {Model}, prompt length: {PromptLength}", + nativeEndpoint, modelId, request.Prompt.Length); + + var requestJson = JsonSerializer.Serialize(requestBody, DefaultJsonOptions); + using var httpContent = new StringContent(requestJson, System.Text.Encoding.UTF8, "application/json"); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, nativeEndpoint) + { + Content = httpContent + }; + + using var httpResponse = await client.SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, cancellationToken); + + if (!httpResponse.IsSuccessStatusCode) + { + var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + var errorMessage = TryExtractCloudflareError(errorContent) + ?? $"Cloudflare Workers AI returned {httpResponse.StatusCode}"; + Logger.LogError("Cloudflare image generation failed: {StatusCode} - {Error}", httpResponse.StatusCode, errorMessage); + throw new LLMCommunicationException($"Cloudflare image generation failed: {errorMessage}"); + } + + var contentType = httpResponse.Content.Headers.ContentType?.MediaType ?? ""; + + // JSON response (Flux 1, Lucid Origin) + if (contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) + { + return await ParseJsonImageResponseAsync(httpResponse, cancellationToken); + } + + // Binary response (SD family, DreamShaper, Phoenix) + return await ParseBinaryImageResponseAsync(httpResponse, cancellationToken); + } + + /// + /// Builds the native Cloudflare endpoint URL for image generation. + /// Transforms the OpenAI-compat base URL to the native /ai/run/{model} format. + /// + private string BuildNativeEndpoint(string modelId) + { + // BaseUrl is like: https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1 + // We need: https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/run/{MODEL_ID} + var baseUrl = BaseUrl.TrimEnd('/'); + + // Strip the /v1 suffix to get the /ai base + var aiBasePath = baseUrl; + if (aiBasePath.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) + { + aiBasePath = aiBasePath[..^3]; + } + else if (aiBasePath.EndsWith("/ai", StringComparison.OrdinalIgnoreCase)) + { + // Already at /ai level + } + else + { + // Fallback: append /ai if the URL doesn't follow expected pattern + Logger.LogWarning("Cloudflare base URL '{BaseUrl}' doesn't match expected pattern. Appending /run directly.", baseUrl); + aiBasePath = baseUrl; + } + + return $"{aiBasePath}/run/{modelId}"; + } + + /// + /// Builds the request body for a Cloudflare image generation call. + /// Parameter names vary by model family. + /// + private Dictionary BuildImageRequestBody( + ImageGenerationRequest request, + CloudflareImageModelFamily modelFamily) + { + var body = new Dictionary + { + ["prompt"] = request.Prompt + }; + + // Parse size into width/height + if (!string.IsNullOrEmpty(request.Size)) + { + var dimensions = request.Size.Split('x'); + if (dimensions.Length == 2 + && int.TryParse(dimensions[0], out var width) + && int.TryParse(dimensions[1], out var height)) + { + body["width"] = width; + body["height"] = height; + } + } + + // Map steps parameter (name differs by family) + var stepsKey = modelFamily == CloudflareImageModelFamily.Flux1 ? "steps" : "num_steps"; + + // Map extension data (negative_prompt, guidance, seed, strength, num_steps/steps) + if (request.ExtensionData != null) + { + foreach (var kvp in request.ExtensionData) + { + var key = kvp.Key; + + // Normalize steps parameter name for the target model family + if (key is "num_steps" or "steps") + { + key = stepsKey; + } + + if (!body.ContainsKey(key)) + { + body[key] = ConvertJsonElement(kvp.Value); + } + } + } + + // Image-to-image support (SD family models) + if (!string.IsNullOrEmpty(request.Image) && modelFamily == CloudflareImageModelFamily.StableDiffusion) + { + body["image_b64"] = request.Image; + } + + if (!string.IsNullOrEmpty(request.Mask) && modelFamily == CloudflareImageModelFamily.StableDiffusion) + { + // Cloudflare accepts mask as byte array; pass through base64 as image_b64 for inpainting + body["mask"] = request.Mask; + } + + return body; + } + + /// + /// Parses a JSON response containing a base64-encoded image (Flux 1, Lucid Origin). + /// + private async Task ParseJsonImageResponseAsync( + HttpResponseMessage httpResponse, + CancellationToken cancellationToken) + { + var content = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + + CloudflareImageResponse? response; + try + { + response = JsonSerializer.Deserialize(content, DefaultJsonOptions); + } + catch (JsonException ex) + { + Logger.LogError(ex, "Failed to parse Cloudflare JSON image response"); + throw new LLMCommunicationException("Failed to parse Cloudflare image response", ex); + } + + if (response == null || response.Success == false) + { + var errorMsg = response?.Errors?.FirstOrDefault()?.Message ?? "Unknown error"; + throw new LLMCommunicationException($"Cloudflare image generation failed: {errorMsg}"); + } + + var base64Image = response.Result?.Image; + if (string.IsNullOrEmpty(base64Image)) + { + throw new LLMCommunicationException("Cloudflare returned a successful response but no image data"); + } + + return new ImageData { B64Json = base64Image }; + } + + /// + /// Parses a binary image response (SD family, DreamShaper, Phoenix). + /// Converts the raw bytes to base64. + /// + private async Task ParseBinaryImageResponseAsync( + HttpResponseMessage httpResponse, + CancellationToken cancellationToken) + { + var imageBytes = await httpResponse.Content.ReadAsByteArrayAsync(cancellationToken); + + if (imageBytes.Length == 0) + { + throw new LLMCommunicationException("Cloudflare returned an empty image response"); + } + + var base64String = Convert.ToBase64String(imageBytes); + return new ImageData { B64Json = base64String }; + } + + /// + /// Attempts to extract an error message from a Cloudflare API error response. + /// + private string? TryExtractCloudflareError(string responseContent) + { + try + { + var errorResponse = JsonSerializer.Deserialize(responseContent, DefaultJsonOptions); + if (errorResponse?.Errors != null && errorResponse.Errors.Count > 0) + { + return string.Join("; ", errorResponse.Errors + .Where(e => !string.IsNullOrEmpty(e.Message)) + .Select(e => e.Message)); + } + } + catch + { + // Response is not JSON or not in expected format + } + + return null; + } + + private static object? ConvertJsonElement(JsonElement element) => + JsonElementConverter.ConvertJsonElement(element); + + /// + /// Classifies a Cloudflare model ID into its model family. + /// + private static CloudflareImageModelFamily ClassifyModelFamily(string modelId) + { + if (modelId.Contains("flux-2", StringComparison.OrdinalIgnoreCase)) + return CloudflareImageModelFamily.Flux2; + + if (modelId.Contains("flux-1", StringComparison.OrdinalIgnoreCase)) + return CloudflareImageModelFamily.Flux1; + + if (modelId.Contains("leonardo", StringComparison.OrdinalIgnoreCase)) + return CloudflareImageModelFamily.Leonardo; + + // SD family: stabilityai, bytedance (sdxl-lightning), lykon (dreamshaper), runwayml + return CloudflareImageModelFamily.StableDiffusion; + } + + /// + /// Cloudflare image model families, each with different API behavior. + /// + private enum CloudflareImageModelFamily + { + /// Stable Diffusion variants: SDXL, SDXL Lightning, DreamShaper, SD v1.5. JSON request, binary PNG response. + StableDiffusion, + + /// Flux 1 Schnell. JSON request, base64 JSON response. + Flux1, + + /// Flux 2 models (dev, klein). Multipart form-data request, base64 JSON response. Not yet supported. + Flux2, + + /// Leonardo models (Phoenix, Lucid Origin). JSON request, mixed response format. + Leonardo + } + } + + /// + /// Cloudflare API response wrapper for image generation. + /// + internal class CloudflareImageResponse + { + [JsonPropertyName("result")] + public CloudflareImageResult? Result { get; set; } + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("errors")] + public List? Errors { get; set; } + + [JsonPropertyName("messages")] + public List? Messages { get; set; } + } + + /// + /// The result payload from a Cloudflare image generation call. + /// + internal class CloudflareImageResult + { + [JsonPropertyName("image")] + public string? Image { get; set; } + } + + /// + /// A Cloudflare API error entry. + /// + internal class CloudflareApiError + { + [JsonPropertyName("code")] + public int Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + } +} diff --git a/Shared/ConduitLLM.Providers/Providers/Cloudflare/CloudflareClient.cs b/Shared/ConduitLLM.Providers/Providers/Cloudflare/CloudflareClient.cs new file mode 100644 index 000000000..39cf1c704 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Providers/Cloudflare/CloudflareClient.cs @@ -0,0 +1,120 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Providers.Common.Models; +using ConduitLLM.Providers.Configuration; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Providers.Cloudflare +{ + /// + /// Client for interacting with Cloudflare Workers AI via its OpenAI-compatible API. + /// + /// + /// + /// Cloudflare Workers AI provides serverless AI inference on Cloudflare's global GPU network. + /// This client uses the OpenAI-compatible endpoints at /ai/v1/ for chat completions and embeddings. + /// + /// + /// Key features: + /// - OpenAI-compatible API for chat completions and embeddings + /// - Support for streaming and non-streaming responses + /// - Function calling via tools array + /// - Access to Llama, Qwen, Gemma, and other open-source models + /// + /// + /// Configuration requires a Cloudflare API token and the base URL must include the account ID: + /// https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1 + /// + /// + /// Authentication is handled via Bearer token in the Authorization header. + /// API tokens can be created at https://dash.cloudflare.com/profile/api-tokens + /// + /// + public partial class CloudflareClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient + { + /// + /// Gets the Cloudflare-specific error messages from the configuration registry. + /// + private static ProviderErrorMessages CloudflareErrorMessages => + ProviderConfigurationRegistry.GetErrorMessages(ProviderType.Cloudflare); + + /// + /// Fallback models for Cloudflare Workers AI when the models endpoint is not available. + /// + private static readonly List CloudflareModels = new() + { + // Llama models + ExtendedModelInfo.Create("@cf/meta/llama-3.3-70b-instruct-fp8-fast", "cloudflare", "Llama 3.3 70B Instruct"), + ExtendedModelInfo.Create("@cf/meta/llama-3.1-8b-instruct", "cloudflare", "Llama 3.1 8B Instruct"), + ExtendedModelInfo.Create("@cf/meta/llama-3.1-70b-instruct", "cloudflare", "Llama 3.1 70B Instruct"), + + // Qwen models + ExtendedModelInfo.Create("@cf/qwen/qwen3-30b-a3b", "cloudflare", "Qwen 3 30B"), + ExtendedModelInfo.Create("@cf/qwen/qwen3-8b", "cloudflare", "Qwen 3 8B"), + + // Gemma models + ExtendedModelInfo.Create("@cf/google/gemma-3-12b-it", "cloudflare", "Gemma 3 12B IT"), + + // Embedding models + ExtendedModelInfo.Create("@cf/baai/bge-m3", "cloudflare", "BGE-M3 Embedding"), + + // Image generation models + ExtendedModelInfo.Create("@cf/black-forest-labs/flux-1-schnell", "cloudflare", "Flux 1 Schnell"), + ExtendedModelInfo.Create("@cf/stabilityai/stable-diffusion-xl-base-1.0", "cloudflare", "Stable Diffusion XL Base"), + ExtendedModelInfo.Create("@cf/bytedance/stable-diffusion-xl-lightning", "cloudflare", "SDXL Lightning"), + ExtendedModelInfo.Create("@cf/lykon/dreamshaper-8-lcm", "cloudflare", "DreamShaper 8 LCM"), + ExtendedModelInfo.Create("@cf/runwayml/stable-diffusion-v1-5-img2img", "cloudflare", "SD 1.5 Img2Img"), + ExtendedModelInfo.Create("@cf/runwayml/stable-diffusion-v1-5-inpainting", "cloudflare", "SD 1.5 Inpainting"), + ExtendedModelInfo.Create("@cf/leonardo/phoenix-1.0", "cloudflare", "Leonardo Phoenix 1.0"), + ExtendedModelInfo.Create("@cf/leonardo/lucid-origin", "cloudflare", "Leonardo Lucid Origin"), + }; + + /// + /// Initializes a new instance of the class. + /// + /// The provider configuration. + /// The API key credential (Cloudflare API token). + /// The model identifier (e.g., @cf/meta/llama-3.3-70b-instruct-fp8-fast). + /// Logger for recording diagnostic information. + /// Factory for creating HttpClient instances. + /// Optional default model configuration for the provider. + /// Thrown when API key is missing. + public CloudflareClient( + Provider provider, + ProviderKeyCredential keyCredential, + string providerModelId, + ILogger logger, + IHttpClientFactory httpClientFactory, + ProviderDefaultModels? defaultModels = null) + : base( + provider, + keyCredential, + providerModelId, + logger, + httpClientFactory, + "Cloudflare", + baseUrl: ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.Cloudflare), + defaultModels: defaultModels) + { + if (string.IsNullOrWhiteSpace(keyCredential.ApiKey)) + { + throw new ConfigurationException(CloudflareErrorMessages.MissingApiKey); + } + } + + /// + /// Configures the HTTP client for Cloudflare Workers AI requests. + /// + /// The HTTP client to configure. + /// The API key to configure authentication with. + protected override void ConfigureHttpClient(HttpClient client, string apiKey) + { + base.ConfigureHttpClient(client, apiKey); + + // Set User-Agent for better API analytics + client.DefaultRequestHeaders.UserAgent.ParseAdd("ConduitLLM-CloudflareClient/1.0"); + } + } +} diff --git a/Shared/ConduitLLM.Providers/Providers/DeepInfra/DeepInfraClient.cs b/Shared/ConduitLLM.Providers/Providers/DeepInfra/DeepInfraClient.cs index 8c80d403e..df680ddb7 100644 --- a/Shared/ConduitLLM.Providers/Providers/DeepInfra/DeepInfraClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/DeepInfra/DeepInfraClient.cs @@ -1,8 +1,6 @@ -using System.Net.Http.Headers; - using ConduitLLM.Configuration; using ConduitLLM.Configuration.Entities; -using ConduitLLM.Core.Models; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; @@ -14,7 +12,7 @@ namespace ConduitLLM.Providers.DeepInfra /// /// /// DeepInfra provides a fully OpenAI-compatible API with access to cutting-edge models - /// including advanced reasoning and coding specialists. This client extends OpenAICompatibleClient + /// including advanced reasoning and coding specialists. This client extends OpenAICompatibleClient /// to provide DeepInfra-specific configuration and behavior. /// /// @@ -32,9 +30,6 @@ namespace ConduitLLM.Providers.DeepInfra /// public class DeepInfraClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient { - // Default base URL for DeepInfra OpenAI-compatible API - private const string DefaultDeepInfraBaseUrl = "https://api.deepinfra.com/v1/openai"; - /// /// Initializes a new instance of the class. /// @@ -58,159 +53,9 @@ public DeepInfraClient( logger, httpClientFactory, "DeepInfra", - baseUrl: DefaultDeepInfraBaseUrl, + baseUrl: ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.DeepInfra), defaultModels: defaultModels) { } - - /// - /// Configures the HTTP client with DeepInfra-specific settings. - /// - /// The HTTP client to configure. - /// The API key to use for authentication. - protected override void ConfigureHttpClient(HttpClient client, string apiKey) - { - // Call base implementation to set standard headers - base.ConfigureHttpClient(client, apiKey); - - // DeepInfra uses OpenAI-compatible Authentication with Bearer token - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - } - - /// - /// Validates credentials for DeepInfra. - /// - protected override void ValidateCredentials() - { - base.ValidateCredentials(); - - // DeepInfra requires an API key - if (string.IsNullOrWhiteSpace(PrimaryKeyCredential.ApiKey)) - { - throw new Core.Exceptions.ConfigurationException($"API key is missing for provider '{ProviderName}'."); - } - } - - /// - /// Creates embeddings using DeepInfra API. - /// - /// The embedding request. - /// Optional API key to override the one in credentials. - /// A token to monitor for cancellation requests. - /// An embedding response. - /// - /// DeepInfra supports embeddings through their OpenAI-compatible API. - /// The model should come from the request or the model mapping system. - /// - public override async Task CreateEmbeddingAsync( - EmbeddingRequest request, - string? apiKey = null, - CancellationToken cancellationToken = default) - { - // Use the base implementation for the actual API call - return await base.CreateEmbeddingAsync(request, apiKey, cancellationToken); - } - - /// - /// Creates images using DeepInfra API. - /// - /// The image generation request. - /// Optional API key to override the one in credentials. - /// A token to monitor for cancellation requests. - /// An image generation response. - /// - /// DeepInfra supports image generation through their OpenAI-compatible API. - /// - public override async Task CreateImageAsync( - ImageGenerationRequest request, - string? apiKey = null, - CancellationToken cancellationToken = default) - { - // DeepInfra supports image generation via OpenAI-compatible endpoint - return await base.CreateImageAsync(request, apiKey, cancellationToken); - } - - #region Authentication Verification - - /// - /// Verifies DeepInfra authentication by making a test request to the models endpoint. - /// - public override async Task VerifyAuthenticationAsync( - string? apiKey = null, - string? baseUrl = null, - CancellationToken cancellationToken = default) - { - try - { - var startTime = DateTime.UtcNow; - var effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey; - - if (string.IsNullOrWhiteSpace(effectiveApiKey)) - { - return Core.Interfaces.AuthenticationResult.Failure( - "API key is required", - "No API key provided for DeepInfra authentication"); - } - - // Create a test client - using var client = CreateHttpClient(effectiveApiKey); - - // Make a request to the models endpoint - var modelsUrl = $"{GetHealthCheckUrl(baseUrl)}/models"; - var response = await client.GetAsync(modelsUrl, cancellationToken); - var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; - - Logger.LogInformation("DeepInfra auth check returned status {StatusCode}", response.StatusCode); - - // Check for authentication errors - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { - return Core.Interfaces.AuthenticationResult.Failure( - "Authentication failed", - "Invalid API key - DeepInfra requires a valid API key"); - } - - if (response.IsSuccessStatusCode) - { - return Core.Interfaces.AuthenticationResult.Success( - "Connected successfully to DeepInfra API", - responseTime); - } - - // Other errors - return Core.Interfaces.AuthenticationResult.Failure( - $"Unexpected response: {response.StatusCode}", - await response.Content.ReadAsStringAsync(cancellationToken)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error verifying DeepInfra authentication"); - return Core.Interfaces.AuthenticationResult.Failure( - $"Authentication verification failed: {ex.Message}", - ex.ToString()); - } - } - - /// - /// Gets the health check URL for DeepInfra. - /// - public override string GetHealthCheckUrl(string? baseUrl = null) - { - var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) - ? baseUrl.TrimEnd('/') - : (Provider.BaseUrl ?? DefaultDeepInfraBaseUrl).TrimEnd('/'); - - return effectiveBaseUrl; - } - - /// - /// Gets the default base URL for DeepInfra. - /// - protected override string GetDefaultBaseUrl() - { - return DefaultDeepInfraBaseUrl; - } - - #endregion } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/Fireworks/FireworksClient.cs b/Shared/ConduitLLM.Providers/Providers/Fireworks/FireworksClient.cs index e61a0e05e..7f97e58d3 100644 --- a/Shared/ConduitLLM.Providers/Providers/Fireworks/FireworksClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/Fireworks/FireworksClient.cs @@ -1,8 +1,7 @@ -using System.Net.Http.Headers; - using ConduitLLM.Configuration; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Models; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; @@ -23,13 +22,11 @@ namespace ConduitLLM.Providers.Fireworks /// public class FireworksClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient { - // Default base URL for Fireworks API - private const string DefaultFireworksBaseUrl = "https://api.fireworks.ai/inference/v1"; - /// /// Initializes a new instance of the class. /// - /// The credentials for accessing the Fireworks API. + /// The provider configuration. + /// The API key credential. /// The model identifier to use (e.g., accounts/fireworks/models/llama-v3-8b-instruct). /// The logger to use. /// Optional HTTP client factory for advanced usage scenarios. @@ -48,64 +45,11 @@ public FireworksClient( logger, httpClientFactory, "Fireworks", - baseUrl: DefaultFireworksBaseUrl, + baseUrl: ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.Fireworks), defaultModels: defaultModels) { } - /// - /// Configures the HTTP client with Fireworks-specific settings. - /// - /// The HTTP client to configure. - /// The API key to use for authentication. - protected override void ConfigureHttpClient(HttpClient client, string apiKey) - { - // Call base implementation to set standard headers - base.ConfigureHttpClient(client, apiKey); - - // Fireworks uses OpenAI-compatible Authentication with Bearer token - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - - // Set any Fireworks-specific headers if needed - // client.DefaultRequestHeaders.Add("Fireworks-Version", "2023-12-01"); - } - - - /// - /// Validates credentials for Fireworks. - /// - protected override void ValidateCredentials() - { - base.ValidateCredentials(); - - // Fireworks requires an API key - if (string.IsNullOrWhiteSpace(PrimaryKeyCredential.ApiKey)) - { - throw new Core.Exceptions.ConfigurationException($"API key is missing for provider '{ProviderName}'."); - } - } - - /// - /// Creates embeddings using Fireworks API. - /// - /// The embedding request. - /// Optional API key to override the one in credentials. - /// A token to monitor for cancellation requests. - /// An embedding response. - /// - /// Note that Fireworks may have a limited set of embedding models available compared to OpenAI. - /// If embedding request fails, check if the model is supported by Fireworks. - /// - public override async Task CreateEmbeddingAsync( - EmbeddingRequest request, - string? apiKey = null, - CancellationToken cancellationToken = default) - { - // Use the base implementation for the actual API call - // The model should come from the request or the model mapping system, not be hardcoded - return await base.CreateEmbeddingAsync(request, apiKey, cancellationToken); - } - /// /// Creates images using Fireworks API. /// @@ -126,88 +70,5 @@ public override Task CreateImageAsync( return Task.FromException( new NotSupportedException("Image generation is not supported by Fireworks")); } - - #region Authentication Verification - - /// - /// Verifies Fireworks authentication by making a test request to the models endpoint. - /// - public override async Task VerifyAuthenticationAsync( - string? apiKey = null, - string? baseUrl = null, - CancellationToken cancellationToken = default) - { - try - { - var startTime = DateTime.UtcNow; - var effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey; - - if (string.IsNullOrWhiteSpace(effectiveApiKey)) - { - return Core.Interfaces.AuthenticationResult.Failure( - "API key is required", - "No API key provided for Fireworks authentication"); - } - - // Create a test client - using var client = CreateHttpClient(effectiveApiKey); - - // Make a request to the models endpoint - var modelsUrl = $"{GetHealthCheckUrl(baseUrl)}/models"; - var response = await client.GetAsync(modelsUrl, cancellationToken); - var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; - - Logger.LogInformation("Fireworks auth check returned status {StatusCode}", response.StatusCode); - - // Check for authentication errors - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { - return Core.Interfaces.AuthenticationResult.Failure( - "Authentication failed", - "Invalid API key - Fireworks requires a valid API key"); - } - - if (response.IsSuccessStatusCode) - { - return Core.Interfaces.AuthenticationResult.Success( - "Connected successfully to Fireworks API", - responseTime); - } - - // Other errors - return Core.Interfaces.AuthenticationResult.Failure( - $"Unexpected response: {response.StatusCode}", - await response.Content.ReadAsStringAsync(cancellationToken)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error verifying Fireworks authentication"); - return Core.Interfaces.AuthenticationResult.Failure( - $"Authentication verification failed: {ex.Message}", - ex.ToString()); - } - } - - /// - /// Gets the health check URL for Fireworks. - /// - public override string GetHealthCheckUrl(string? baseUrl = null) - { - var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) - ? baseUrl.TrimEnd('/') - : (Provider.BaseUrl ?? DefaultFireworksBaseUrl).TrimEnd('/'); - - return effectiveBaseUrl; - } - - /// - /// Gets the default base URL for Fireworks. - /// - protected override string GetDefaultBaseUrl() - { - return DefaultFireworksBaseUrl; - } - - #endregion } } diff --git a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Authentication.cs b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Authentication.cs deleted file mode 100644 index cc297f4ce..000000000 --- a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Authentication.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Providers.Groq -{ - /// - /// GroqClient partial class containing authentication methods. - /// - public partial class GroqClient - { - /// - /// Verifies Groq authentication by making a test request to the models endpoint. - /// - public override async Task VerifyAuthenticationAsync( - string? apiKey = null, - string? baseUrl = null, - CancellationToken cancellationToken = default) - { - try - { - var startTime = DateTime.UtcNow; - var effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey; - - if (string.IsNullOrWhiteSpace(effectiveApiKey)) - { - return ConduitLLM.Core.Interfaces.AuthenticationResult.Failure( - "API key is required", - "No API key provided for Groq authentication"); - } - - // Create a test client - using var client = CreateHttpClient(effectiveApiKey); - - // Make a request to the models endpoint - var modelsUrl = $"{GetHealthCheckUrl(baseUrl)}/models"; - var response = await client.GetAsync(modelsUrl, cancellationToken); - var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; - - Logger.LogInformation("Groq auth check returned status {StatusCode}", response.StatusCode); - - // Check for authentication errors - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) - { - return ConduitLLM.Core.Interfaces.AuthenticationResult.Failure( - "Authentication failed", - "Invalid API key - Groq requires a valid API key"); - } - - if (response.IsSuccessStatusCode) - { - return ConduitLLM.Core.Interfaces.AuthenticationResult.Success( - "Connected successfully to Groq API", - responseTime); - } - - // Other errors - return ConduitLLM.Core.Interfaces.AuthenticationResult.Failure( - $"Unexpected response: {response.StatusCode}", - await response.Content.ReadAsStringAsync(cancellationToken)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error verifying Groq authentication"); - return ConduitLLM.Core.Interfaces.AuthenticationResult.Failure( - $"Authentication verification failed: {ex.Message}", - ex.ToString()); - } - } - - /// - /// Gets the health check URL for Groq. - /// - public override string GetHealthCheckUrl(string? baseUrl = null) - { - var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) - ? baseUrl.TrimEnd('/') - : (!string.IsNullOrWhiteSpace(Provider.BaseUrl) - ? Provider.BaseUrl.TrimEnd('/') - : Constants.Urls.DefaultBaseUrl.TrimEnd('/')); - - return effectiveBaseUrl; - } - } -} diff --git a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Chat.cs b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Chat.cs index 2ff02d5bb..f30ddff6e 100644 --- a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Chat.cs +++ b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Chat.cs @@ -1,16 +1,10 @@ -using System.Runtime.CompilerServices; - using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Models; using Microsoft.Extensions.Logging; -using CoreUtils = ConduitLLM.Core.Utilities; - using CoreModels = ConduitLLM.Core.Models; -using OpenAI = ConduitLLM.Providers.OpenAI; - namespace ConduitLLM.Providers.Groq { /// @@ -56,115 +50,10 @@ public override async Task CreateChatCompletionAsync( } /// - /// Streams a chat completion with enhanced error handling specific to Groq. + /// Transforms raw chunk JSON to extract Groq's x_groq.usage into the standard usage field. /// - /// The chat completion request. - /// Optional API key to override the one in credentials. - /// A token to monitor for cancellation requests. - /// An async enumerable of chat completion chunks. - /// Thrown when there is a communication error with Groq. - public override async IAsyncEnumerable StreamChatCompletionAsync( - ChatCompletionRequest request, - string? apiKey = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ValidateRequest(request, "StreamChatCompletion"); - - // Stream chunks progressively with Groq-specific processing - await foreach (var chunk in StreamChunksProgressivelyAsync(request, apiKey, cancellationToken).WithCancellation(cancellationToken)) - { - if (cancellationToken.IsCancellationRequested) - { - yield break; - } - - yield return chunk; - } - } - - /// - /// Streams chunks progressively with Groq-specific processing to extract usage from x_groq field. - /// - private async IAsyncEnumerable StreamChunksProgressivelyAsync( - ChatCompletionRequest request, - string? apiKey = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - HttpClient? client = null; - HttpResponseMessage? response = null; - - try - { - client = CreateHttpClient(apiKey); - var openAiRequest = PrepareStreamingRequest(request); - var endpoint = GetChatCompletionEndpoint(); - - Logger.LogDebug("Sending streaming chat completion request to Groq at {Endpoint}", endpoint); - - response = await CoreUtils.HttpClientHelper.SendStreamingRequestAsync( - client, - HttpMethod.Post, - endpoint, - openAiRequest, - CreateStandardHeaders(apiKey), - DefaultJsonOptions, - Logger, - cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Process the error with enhanced error extraction - var enhancedErrorMessage = ExtractEnhancedErrorMessage(ex); - Logger.LogError(ex, "Error in streaming chat completion from Groq: {Message}", enhancedErrorMessage); - - var error = CoreUtils.ExceptionHandler.HandleLlmException(ex, Logger, ProviderName, request.Model ?? ProviderModelId); - - // Clean up resources - response?.Dispose(); - client?.Dispose(); - - throw error; - } - - // If we get here, we have a response to stream - if (response != null) - { - // Stream chunks progressively using StreamHelper - use JsonElement for raw passthrough - await foreach (var chunk in CoreUtils.StreamHelper.ProcessSseStreamAsync( - response, Logger, DefaultJsonOptions, cancellationToken)) - { - if (cancellationToken.IsCancellationRequested) - { - response.Dispose(); - client?.Dispose(); - yield break; - } - - // Process the raw JSON to extract x_groq.usage and map it to standard usage field - var processedJson = ProcessGroqChunkJson(chunk); - - // Deserialize the processed JSON to our chunk type - var mappedChunk = System.Text.Json.JsonSerializer.Deserialize( - processedJson, DefaultJsonOptions); - - if (mappedChunk != null) - { - // Preserve the original model alias if provided - if (!string.IsNullOrEmpty(request.Model)) - { - mappedChunk.Model = request.Model; - mappedChunk.OriginalModelAlias = request.Model; - } - - yield return mappedChunk; - } - } - - // Clean up after successful streaming - response.Dispose(); - client?.Dispose(); - } - } + protected override string TransformChunkJson(System.Text.Json.JsonElement chunk) + => ProcessGroqChunkJson(chunk); /// /// Processes a Groq chunk JSON to extract x_groq.usage and map it to the standard usage field. @@ -237,40 +126,6 @@ private string ProcessGroqChunkJson(System.Text.Json.JsonElement chunk) return chunk.GetRawText(); } - /// - /// Prepares a request for streaming by ensuring the stream parameter is set to true. - /// - private object PrepareStreamingRequest(ChatCompletionRequest request) - { - var openAiRequest = MapToOpenAIRequest(request); - - // Force stream parameter to true based on the request's type - if (openAiRequest is System.Text.Json.JsonElement jsonElement) - { - var jsonObject = jsonElement.GetRawText(); - var tempObj = System.Text.Json.JsonSerializer.Deserialize>(jsonObject, DefaultJsonOptions); - if (tempObj != null) - { - tempObj["stream"] = true; - return tempObj; - } - return jsonElement; - } - else if (openAiRequest is Dictionary dictObj) - { - dictObj["stream"] = true; - return dictObj; - } - else if (openAiRequest is OpenAI.OpenAIChatCompletionRequest reqObj) - { - reqObj = reqObj with { Stream = true }; - return reqObj; - } - - // If we can't determine the type, return the original request - return openAiRequest; - } - /// /// Maps the Groq non-streaming response to provider-agnostic format, handling Groq's usage data. /// diff --git a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.ErrorHandling.cs b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.ErrorHandling.cs index 7b4dc0475..5e820d64e 100644 --- a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.ErrorHandling.cs +++ b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.ErrorHandling.cs @@ -1,3 +1,6 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Providers.Configuration; + namespace ConduitLLM.Providers.Groq { /// @@ -5,59 +8,46 @@ namespace ConduitLLM.Providers.Groq /// public partial class GroqClient { + /// + /// Gets the Groq-specific error messages from the configuration registry. + /// + private static ProviderErrorMessages GroqErrorMessages => + ProviderConfigurationRegistry.GetErrorMessages(ProviderType.Groq); + /// /// Extracts a more helpful error message from exception details for Groq errors. + /// Adds Groq-specific keyword matching on top of base extraction. /// - /// The exception to extract information from. - /// An enhanced error message specific to Groq errors. - /// - /// This overrides the base implementation to provide more specific error extraction for Groq. - /// protected override string ExtractEnhancedErrorMessage(Exception ex) { - // Use the base implementation first - var baseErrorMessage = base.ExtractEnhancedErrorMessage(ex); + var baseResult = base.ExtractEnhancedErrorMessage(ex); - // If the base implementation found a useful message, return it - if (!string.IsNullOrEmpty(baseErrorMessage) && - !baseErrorMessage.Equals(ex.Message) && - !baseErrorMessage.Contains("Exception of type")) + // If the base found something useful beyond the raw message, use it + if (!string.IsNullOrEmpty(baseResult) && + !baseResult.Equals(ex.Message) && + !baseResult.Contains("Exception of type")) { - return baseErrorMessage; + return baseResult; } - // Groq-specific error extraction + // Groq-specific keyword matching var msg = ex.Message; - // If we find "model not found" in the message, provide a more helpful message if (msg.Contains("model not found", StringComparison.OrdinalIgnoreCase) || - msg.Contains("The model", StringComparison.OrdinalIgnoreCase) && - msg.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) + (msg.Contains("The model", StringComparison.OrdinalIgnoreCase) && + msg.Contains("does not exist", StringComparison.OrdinalIgnoreCase))) { - return Constants.ErrorMessages.ModelNotFound; + return GroqErrorMessages.ModelNotFound; } - // For rate limit errors, provide a clearer message if (msg.Contains("rate limit", StringComparison.OrdinalIgnoreCase) || msg.Contains("too many requests", StringComparison.OrdinalIgnoreCase)) { - return Constants.ErrorMessages.RateLimitExceeded; - } - - // Look for Body data - if (ex.Data.Contains("Body") && ex.Data["Body"] is string body && !string.IsNullOrEmpty(body)) - { - return $"Groq API error: {body}"; - } - - // Try inner exception - if (ex.InnerException != null && !string.IsNullOrEmpty(ex.InnerException.Message)) - { - return $"Groq API error: {ex.InnerException.Message}"; + return GroqErrorMessages.RateLimitExceeded; } - // Fallback to original message - return $"Groq API error: {msg}"; + // Fallback: use base result with provider prefix + return $"Groq API error: {baseResult}"; } } } diff --git a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Models.cs b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Models.cs deleted file mode 100644 index f1f85d02f..000000000 --- a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.Models.cs +++ /dev/null @@ -1,34 +0,0 @@ -using ConduitLLM.Providers.Common.Models; - -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Providers.Groq -{ - /// - /// GroqClient partial class containing model discovery methods. - /// - public partial class GroqClient - { - /// - /// Gets available models from the Groq API or falls back to a predefined list. - /// - /// Optional API key to override the one in credentials. - /// A token to monitor for cancellation requests. - /// A list of available models from Groq. - public override async Task> GetModelsAsync( - string? apiKey = null, - CancellationToken cancellationToken = default) - { - try - { - // Attempt to use the generic OpenAI-compatible /models endpoint - return await base.GetModelsAsync(apiKey, cancellationToken); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to retrieve models from Groq API."); - throw; - } - } - } -} diff --git a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.cs b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.cs index 2cd43b2ad..2972aeb9e 100644 --- a/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/Groq/GroqClient.cs @@ -1,5 +1,6 @@ using ConduitLLM.Configuration; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; @@ -14,34 +15,12 @@ namespace ConduitLLM.Providers.Groq /// It provides optimized inference for popular open-source models like Llama, Mixtral, and Gemma. /// /// - /// This client leverages the OpenAI-compatible base implementation and adds - /// Groq-specific error handling and fallback mechanisms. + /// This client leverages the OpenAI-compatible base implementation and adds + /// Groq-specific error handling and streaming usage extraction. /// /// public partial class GroqClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient { - // API configuration constants - private static class Constants - { - public static class Urls - { - public const string DefaultBaseUrl = "https://api.groq.com/openai/v1"; - } - - public static class Endpoints - { - public const string ChatCompletions = "/chat/completions"; - public const string Models = "/models"; - public const string Completions = "/completions"; - } - - public static class ErrorMessages - { - public const string ModelNotFound = "Model not found. Available Groq models include: llama3-8b-8192, llama3-70b-8192, llama2-70b-4096, mixtral-8x7b-32768, gemma-7b-it"; - public const string RateLimitExceeded = "Groq API rate limit exceeded. Please try again later or reduce your request frequency."; - } - } - /// /// Initializes a new instance of the class. /// @@ -65,9 +44,9 @@ public GroqClient( logger, httpClientFactory, "groq", - baseUrl: !string.IsNullOrWhiteSpace(provider.BaseUrl) - ? provider.BaseUrl - : Constants.Urls.DefaultBaseUrl, + baseUrl: !string.IsNullOrWhiteSpace(provider.BaseUrl) + ? provider.BaseUrl + : ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.Groq), defaultModels: defaultModels) { } diff --git a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Chat.cs b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Chat.cs index b606d461a..4c08538ec 100644 --- a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Chat.cs +++ b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Chat.cs @@ -44,38 +44,34 @@ public override async Task CreateChatCompletionAsync( // MiniMax uses different endpoints for streaming vs non-streaming // Streaming uses the v2 API which requires name fields in messages - var endpoint = request.Stream == true + var endpoint = request.Stream == true ? $"{_baseUrl}/v1/text/chatcompletion_v2" : $"{_baseUrl}/v1/chat/completions"; - // Log the request for debugging + var requestJson = JsonSerializer.Serialize(miniMaxRequest); - Logger.LogInformation("MiniMax request: {Request}", requestJson); + if (Logger.IsEnabled(LogLevel.Debug)) + { + Logger.LogDebug("MiniMax request to {Endpoint}: {Request}", endpoint, requestJson); + } - // Make direct HTTP call to debug var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint); httpRequest.Content = new StringContent(requestJson, Encoding.UTF8, "application/json"); - - var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); + + using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); var rawContent = await httpResponse.Content.ReadAsStringAsync(); - - Logger.LogInformation("MiniMax HTTP Status: {Status}", httpResponse.StatusCode); - Logger.LogInformation("MiniMax raw response: {Response}", rawContent); - + + Logger.LogDebug("MiniMax HTTP Status: {Status}", httpResponse.StatusCode); + if (!httpResponse.IsSuccessStatusCode) { + Logger.LogError("MiniMax API returned {Status}: {Response}", httpResponse.StatusCode, rawContent); throw new LLMCommunicationException($"MiniMax API returned {httpResponse.StatusCode}: {rawContent}"); } - - // Now deserialize + MiniMaxChatCompletionResponse response; try { - response = JsonSerializer.Deserialize(rawContent, new JsonSerializerOptions - { - // MiniMax uses snake_case, not camelCase - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - })!; + response = JsonSerializer.Deserialize(rawContent, CaseInsensitiveJsonOptions)!; } catch (Exception ex) { @@ -83,38 +79,25 @@ public override async Task CreateChatCompletionAsync( throw new LLMCommunicationException("Failed to deserialize MiniMax response", ex); } - // Log the raw response for debugging if (response == null) { Logger.LogWarning("MiniMax response is null"); throw new LLMCommunicationException("MiniMax returned null response"); } - var responseJson = JsonSerializer.Serialize(response); - Logger.LogInformation("MiniMax response: {Response}", responseJson); - Logger.LogInformation("MiniMax response choices count: {Count}", response.Choices?.Count ?? 0); - if (response.Choices != null && response.Choices.Count() > 0) - { - Logger.LogInformation("First choice message: {Message}", - JsonSerializer.Serialize(response.Choices[0].Message)); - var message = response.Choices[0].Message; - if (message != null) - { - Logger.LogInformation("Message content: '{Content}', ReasoningContent: '{Reasoning}'", - message.Content ?? "", - message.ReasoningContent ?? ""); - } - } + Logger.LogDebug("MiniMax response choices count: {Count}", response.Choices?.Count ?? 0); // Check for MiniMax error response if (response.BaseResp is { } baseResp && baseResp.StatusCode != 0) { - Logger.LogError("MiniMax error: {StatusCode} - {StatusMsg}", + Logger.LogError("MiniMax error: {StatusCode} - {StatusMsg}", baseResp.StatusCode, baseResp.StatusMsg); throw new LLMCommunicationException($"MiniMax error: {baseResp.StatusMsg}"); } - return ConvertToCoreResponse(response, request.Model ?? ProviderModelId); + var coreResponse = ConvertToCoreResponse(response, request.Model ?? ProviderModelId); + RecordUsage(coreResponse.Usage, "CreateChatCompletion"); + return coreResponse; }, "CreateChatCompletion", cancellationToken); } } diff --git a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Images.cs b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Images.cs index a6ce5dd79..ba4b032aa 100644 --- a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Images.cs +++ b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Images.cs @@ -51,7 +51,7 @@ public override async Task CreateImageAsync( var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint); httpRequest.Content = new StringContent(requestJson, Encoding.UTF8, "application/json"); - var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); + using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); var rawContent = await httpResponse.Content.ReadAsStringAsync(); Logger.LogInformation("MiniMax HTTP Status: {Status}", httpResponse.StatusCode); @@ -66,14 +66,10 @@ public override async Task CreateImageAsync( MiniMaxImageGenerationResponse response; try { - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - response = JsonSerializer.Deserialize(rawContent, options)!; + response = JsonSerializer.Deserialize(rawContent, DefaultJsonOptions) + ?? throw new LLMCommunicationException("MiniMax returned null response"); } - catch (Exception ex) + catch (JsonException ex) { Logger.LogError(ex, "Error deserializing MiniMax response: {Response}", rawContent); throw new LLMCommunicationException("Failed to deserialize MiniMax response", ex); diff --git a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Streaming.cs b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Streaming.cs index f3709213a..312c6b3ac 100644 --- a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Streaming.cs +++ b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Streaming.cs @@ -20,162 +20,147 @@ public override async IAsyncEnumerable StreamChatCompletion { ValidateRequest(request, "StreamChatCompletion"); - // Process the stream and collect chunks or errors - var (chunks, error) = await ProcessStreamAsync(request, apiKey, cancellationToken); - - // If there was an error, throw it - if (error != null) - { - throw error; - } - - // Yield all the chunks - foreach (var chunk in chunks) - { - if (cancellationToken.IsCancellationRequested) - { - Logger.LogInformation("MiniMax streaming cancelled during yield"); - yield break; - } - yield return chunk; - } - } - - private async Task<(List chunks, Exception? error)> ProcessStreamAsync( - ChatCompletionRequest request, - string? apiKey, - CancellationToken cancellationToken) - { - var chunks = new List(); + using var logScope = BeginProviderLogScope("StreamChatCompletion"); + var instrumentation = BeginStreamingScope("StreamChatCompletion"); HttpClient? httpClient = null; HttpResponseMessage? response = null; - + bool reportedUsage = false; + try { - httpClient = CreateHttpClient(apiKey); - - var miniMaxRequest = new MiniMaxChatCompletionRequest + try { - Model = request.Model ?? ProviderModelId, - Messages = ConvertMessages(request.Messages, includeNames: true), - Stream = true, - MaxTokens = request.MaxTokens, - Temperature = request.Temperature, - TopP = request.TopP, - Tools = ConvertTools(request.Tools), - ToolChoice = ConvertToolChoice(request.ToolChoice), - ReplyConstraints = request.ResponseFormat != null ? new ReplyConstraints + httpClient = CreateHttpClient(apiKey); + + var miniMaxRequest = new MiniMaxChatCompletionRequest + { + Model = request.Model ?? ProviderModelId, + Messages = ConvertMessages(request.Messages, includeNames: true), + Stream = true, + MaxTokens = request.MaxTokens, + Temperature = request.Temperature, + TopP = request.TopP, + Tools = ConvertTools(request.Tools), + ToolChoice = ConvertToolChoice(request.ToolChoice), + ReplyConstraints = request.ResponseFormat != null ? new ReplyConstraints + { + GuidanceType = request.ResponseFormat.Type == "json_object" ? "json_schema" : null, + JsonSchema = request.ResponseFormat.Type == "json_object" ? new { type = "object" } : null + } : null + }; + + var endpoint = $"{_baseUrl}/v1/text/chatcompletion_v2"; + + if (Logger.IsEnabled(LogLevel.Debug)) { - GuidanceType = request.ResponseFormat.Type == "json_object" ? "json_schema" : null, - JsonSchema = request.ResponseFormat.Type == "json_object" ? new { type = "object" } : null - } : null - }; - - // MiniMax streaming uses the v2 API endpoint - var endpoint = $"{_baseUrl}/v1/text/chatcompletion_v2"; - - // Log the full request details for debugging - var requestJson = System.Text.Json.JsonSerializer.Serialize(miniMaxRequest); - Logger.LogInformation("MiniMax Streaming Request to {Endpoint}: {Request}", endpoint, requestJson); - - response = await Core.Utilities.HttpClientHelper.SendStreamingRequestAsync( - httpClient, HttpMethod.Post, endpoint, miniMaxRequest, null, null, Logger, cancellationToken); - - Logger.LogInformation("MiniMax streaming response status: {StatusCode}, Headers: {Headers}", - response.StatusCode, response.Headers.ToString()); - - // Check if response is successful - if (!response.IsSuccessStatusCode) + var requestJson = System.Text.Json.JsonSerializer.Serialize(miniMaxRequest); + Logger.LogDebug("MiniMax Streaming Request to {Endpoint}: {Request}", endpoint, requestJson); + } + + response = await Core.Utilities.HttpClientHelper.SendStreamingRequestAsync( + httpClient, HttpMethod.Post, endpoint, miniMaxRequest, null, null, Logger, cancellationToken); + + Logger.LogDebug("MiniMax streaming response status: {StatusCode}", response.StatusCode); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + Logger.LogError("MiniMax streaming failed with status {Status}: {Content}", + response.StatusCode, errorContent); + throw new LLMCommunicationException($"MiniMax streaming failed: {response.StatusCode} - {errorContent}"); + } + } + catch (OperationCanceledException) + { + instrumentation.RecordFailure(nameof(OperationCanceledException)); + throw; + } + catch (Exception ex) { - var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); - Logger.LogError("MiniMax streaming failed with status {Status}: {Content}", - response.StatusCode, errorContent); - return (chunks, new LLMCommunicationException($"MiniMax streaming failed: {response.StatusCode} - {errorContent}")); + instrumentation.RecordFailure(ex.GetType().Name); + throw; } var streamEnum = Core.Utilities.StreamHelper.ProcessSseStreamAsync( - response, Logger, null, cancellationToken); + response!, Logger, null, cancellationToken); await foreach (var chunk in streamEnum.WithCancellation(cancellationToken)) { - // Check cancellation token explicitly if (cancellationToken.IsCancellationRequested) { Logger.LogInformation("MiniMax streaming cancelled by client"); - break; + yield break; } - - if (chunk != null) + + if (chunk == null) { - Logger.LogDebug("Received MiniMax chunk with ID: {Id}, Choices: {ChoiceCount}", - chunk.Id, chunk.Choices?.Count ?? 0); - - // Check for MiniMax error response - if (chunk.BaseResp is { } baseResp && baseResp.StatusCode != 0) - { - Logger.LogError("MiniMax streaming error: {StatusCode} - {StatusMsg}", - baseResp.StatusCode, baseResp.StatusMsg); - return (chunks, new LLMCommunicationException($"MiniMax error: {baseResp.StatusMsg}")); - } - - ChatCompletionChunk? convertedChunk = null; - - try - { - convertedChunk = ConvertToChunk(chunk, request.Model ?? ProviderModelId); - } - catch (System.Text.Json.JsonException jsonEx) - { - Logger.LogError(jsonEx, "Failed to parse MiniMax chunk. Raw chunk: {Chunk}", - System.Text.Json.JsonSerializer.Serialize(chunk)); - return (chunks, new LLMCommunicationException($"Failed to parse MiniMax chunk: {jsonEx.Message}", jsonEx)); - } - catch (Exception convEx) - { - Logger.LogError(convEx, "Failed to convert MiniMax chunk to standard format"); - return (chunks, new LLMCommunicationException($"Failed to convert MiniMax chunk: {convEx.Message}", convEx)); - } - - if (convertedChunk != null) - chunks.Add(convertedChunk); + Logger.LogDebug("Received null chunk from MiniMax stream"); + continue; } - else + + Logger.LogDebug("Received MiniMax chunk with ID: {Id}, Choices: {ChoiceCount}", + chunk.Id, chunk.Choices?.Count ?? 0); + + // Check for MiniMax error response embedded in SSE stream + if (chunk.BaseResp is { } baseResp && baseResp.StatusCode != 0) { - Logger.LogDebug("Received null chunk from MiniMax stream"); + Logger.LogError("MiniMax streaming error: {StatusCode} - {StatusMsg}", + baseResp.StatusCode, baseResp.StatusMsg); + instrumentation.RecordFailure("MiniMaxStreamError"); + throw new LLMCommunicationException($"MiniMax error: {baseResp.StatusMsg}"); + } + + ChatCompletionChunk convertedChunk; + try + { + convertedChunk = ConvertToChunk(chunk, request.Model ?? ProviderModelId); + } + catch (System.Text.Json.JsonException jsonEx) + { + Logger.LogError(jsonEx, "Failed to parse MiniMax chunk. Raw chunk: {Chunk}", + System.Text.Json.JsonSerializer.Serialize(chunk)); + instrumentation.RecordFailure(nameof(System.Text.Json.JsonException)); + throw new LLMCommunicationException($"Failed to parse MiniMax chunk: {jsonEx.Message}", jsonEx); + } + catch (Exception convEx) + { + Logger.LogError(convEx, "Failed to convert MiniMax chunk to standard format"); + instrumentation.RecordFailure(convEx.GetType().Name); + throw new LLMCommunicationException($"Failed to convert MiniMax chunk: {convEx.Message}", convEx); + } + + instrumentation.RecordChunk(); + + if (!reportedUsage && chunk.Usage is { } chunkUsage && + (chunkUsage.PromptTokens > 0 || chunkUsage.CompletionTokens > 0 || chunkUsage.TotalTokens > 0)) + { + RecordUsage(new Usage + { + PromptTokens = chunkUsage.PromptTokens, + CompletionTokens = chunkUsage.CompletionTokens, + TotalTokens = chunkUsage.TotalTokens + }, "StreamChatCompletion"); + reportedUsage = true; } + + yield return convertedChunk; } - - Logger.LogInformation("MiniMax streaming completed"); - return (chunks, null); - } - catch (OperationCanceledException ex) - { - Logger.LogInformation("MiniMax streaming was cancelled"); - return (chunks, ex); - } - catch (LLMCommunicationException ex) - { - return (chunks, ex); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to send streaming request to MiniMax. Endpoint: {Endpoint}", - $"{_baseUrl}/v1/text/chatcompletion_v2"); - return (chunks, new LLMCommunicationException($"Failed to connect to MiniMax streaming API: {ex.Message}", ex)); + + Logger.LogDebug("MiniMax streaming completed"); } finally { - // Ensure proper resource disposal response?.Dispose(); httpClient?.Dispose(); + instrumentation.Dispose(); } } private ChatCompletionChunk ConvertToChunk(MiniMaxStreamChunk miniMaxChunk, string modelId) { - Logger.LogDebug("Converting MiniMax chunk: Id={Id}, ChoiceCount={ChoiceCount}", + Logger.LogDebug("Converting MiniMax chunk: Id={Id}, ChoiceCount={ChoiceCount}", miniMaxChunk.Id, miniMaxChunk.Choices?.Count ?? 0); - + var chunk = new ChatCompletionChunk { Id = miniMaxChunk.Id ?? Guid.NewGuid().ToString(), @@ -196,51 +181,39 @@ private ChatCompletionChunk ConvertToChunk(MiniMaxStreamChunk miniMaxChunk, stri // - object: "chat.completion" instead of "chat.completion.chunk" // This is redundant (content was already streamed) and breaks OpenAI compatibility. // We must check both delta and message fields to handle this non-standard format. - + string? content = null; string? role = null; MiniMaxFunctionCall? functionCall = null; - + if (choice.Message != null) { - // MiniMax's non-standard final chunk with complete message - // This should NOT exist in OpenAI-compliant streaming Logger.LogDebug("MiniMax non-standard final chunk detected with complete message"); - - // Extract content from the message field - // MiniMax's Message.Content can be string or object, so handle accordingly - // Also check ReasoningContent for models that use reasoning tokens - content = !string.IsNullOrEmpty(choice.Message.Content?.ToString()) + + content = !string.IsNullOrEmpty(choice.Message.Content?.ToString()) ? choice.Message.Content.ToString() : choice.Message.ReasoningContent; role = choice.Message.Role; functionCall = choice.Message.FunctionCall; - - // Since this is the complete message, we only send the final piece - // to avoid duplicating what was already streamed - // This is a workaround for MiniMax's protocol violation + if (!string.IsNullOrEmpty(content) && choice.FinishReason == "stop") { - // Skip the complete message in final chunk to avoid duplication - // The content has already been streamed incrementally Logger.LogDebug("Skipping redundant complete message in MiniMax final chunk"); - content = null; // Don't send the complete message again + content = null; } } else if (choice.Delta != null) { - // Standard OpenAI-compliant streaming chunk with delta - // MiniMax may use ReasoningContent for models with reasoning tokens - content = !string.IsNullOrEmpty(choice.Delta.Content) + content = !string.IsNullOrEmpty(choice.Delta.Content) ? choice.Delta.Content : choice.Delta.ReasoningContent; role = choice.Delta.Role; functionCall = choice.Delta.FunctionCall; } - - Logger.LogDebug("MiniMax choice: Index={Index}, Content={Content}, Role={Role}, FinishReason={FinishReason}, HasMessage={HasMessage}", + + Logger.LogDebug("MiniMax choice: Index={Index}, Content={Content}, Role={Role}, FinishReason={FinishReason}, HasMessage={HasMessage}", choice.Index, content, role, choice.FinishReason, choice.Message != null); - + chunk.Choices.Add(new StreamingChoice { Index = choice.Index, @@ -255,9 +228,6 @@ private ChatCompletionChunk ConvertToChunk(MiniMaxStreamChunk miniMaxChunk, stri } } - // Note: ChatCompletionChunk doesn't have Usage property in standard implementation - // Usage is typically tracked separately or sent in final chunk - return chunk; } @@ -282,4 +252,4 @@ private ChatCompletionChunk ConvertToChunk(MiniMaxStreamChunk miniMaxChunk, stri }; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Utilities.cs b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Utilities.cs index 084d7e7b1..bc731e61b 100644 --- a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Utilities.cs +++ b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Utilities.cs @@ -35,7 +35,7 @@ private List ConvertMessages(List messages, bool includ }; } - if (message.Role == "assistant" && message.ToolCalls != null && message.ToolCalls.Count() > 0) + if (message.Role == "assistant" && message.ToolCalls != null && message.ToolCalls.Any()) { // MiniMax uses function_call format, convert from tool_calls var firstToolCall = message.ToolCalls[0]; @@ -93,7 +93,7 @@ private object ConvertMessageContent(object content) private List? ConvertTools(List? tools) { - if (tools == null || tools.Count() == 0) + if (tools == null || !tools.Any()) return null; var miniMaxTools = new List(); @@ -114,7 +114,7 @@ private object ConvertMessageContent(object content) } } - return miniMaxTools.Count() > 0 ? miniMaxTools : null; + return miniMaxTools.Any() ? miniMaxTools : null; } private object? ConvertToolChoice(ToolChoice? toolChoice) diff --git a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Videos.cs b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Videos.cs index 668595f74..e2df0b7ba 100644 --- a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Videos.cs +++ b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.Videos.cs @@ -1,9 +1,11 @@ +using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Models; +using ConduitLLM.Providers.Helpers; using Microsoft.Extensions.Logging; @@ -78,7 +80,7 @@ public async Task CreateVideoAsync( var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint); httpRequest.Content = new StringContent(requestJson, Encoding.UTF8, "application/json"); - var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); + using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken); var rawContent = await httpResponse.Content.ReadAsStringAsync(); Logger.LogInformation("MiniMax HTTP Status: {Status}", httpResponse.StatusCode); @@ -93,14 +95,10 @@ public async Task CreateVideoAsync( MiniMaxVideoGenerationResponse response; try { - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - response = JsonSerializer.Deserialize(rawContent, options)!; + response = JsonSerializer.Deserialize(rawContent, DefaultJsonOptions) + ?? throw new LLMCommunicationException("MiniMax returned null response"); } - catch (Exception ex) + catch (JsonException ex) { Logger.LogError(ex, "Error deserializing MiniMax video response: {Response}", rawContent); throw new LLMCommunicationException("Failed to deserialize MiniMax video response", ex); @@ -114,290 +112,223 @@ public async Task CreateVideoAsync( throw new LLMCommunicationException($"MiniMax error: {baseResp.StatusMsg}"); } - // If we have a task ID, we need to poll for the result - if (!string.IsNullOrEmpty(response.TaskId)) + if (string.IsNullOrEmpty(response.TaskId)) { - Logger.LogInformation("MiniMax video generation task created: {TaskId}", response.TaskId); - - // Get polling timeout configuration from environment or use default - var pollingTimeoutMinutes = 10; // Default 10 minutes - var envTimeout = Environment.GetEnvironmentVariable("CONDUITLLM__TIMEOUTS__VIDEO_POLLING__SECONDS"); - if (!string.IsNullOrEmpty(envTimeout) && int.TryParse(envTimeout, out var timeoutSeconds)) - { - pollingTimeoutMinutes = timeoutSeconds / 60; - } - - const int basePollingIntervalMs = 2000; // Start with 2 seconds - const int maxPollingIntervalMs = 30000; // Max 30 seconds - var maxPollingAttempts = (pollingTimeoutMinutes * 60 * 1000) / basePollingIntervalMs; - - Logger.LogInformation("Configured video polling timeout: {TimeoutMinutes} minutes, max attempts: {MaxAttempts}", - pollingTimeoutMinutes, maxPollingAttempts); - - var pollingIntervalMs = basePollingIntervalMs; - var consecutiveErrors = 0; - const int maxConsecutiveErrors = 3; - - for (int attempt = 0; attempt < maxPollingAttempts; attempt++) + // Should not reach here for video generation as it's always async + throw new LLMCommunicationException("MiniMax video generation did not return a task ID"); + } + + Logger.LogInformation("MiniMax video generation task created: {TaskId}", response.TaskId); + + var pollingTimeoutSeconds = ResolveVideoPollingTimeoutSeconds(); + var initialDelay = TimeSpan.FromMilliseconds(2000); + var maxDelay = TimeSpan.FromMilliseconds(30000); + var approxMaxAttempts = (int)(pollingTimeoutSeconds / initialDelay.TotalSeconds); + + Logger.LogInformation("Configured video polling timeout: {TimeoutSeconds}s, approx max attempts: {MaxAttempts}", + pollingTimeoutSeconds, approxMaxAttempts); + + var options = new PollingOptions( + InitialDelay: initialDelay, + MaxDelay: maxDelay, + Timeout: TimeSpan.FromSeconds(pollingTimeoutSeconds), + Backoff: BackoffStrategy.ExponentialWithJitter, + MaxConsecutiveTransientErrors: 3, + BackoffMultiplier: 1.5, + JitterMilliseconds: 500); + + var taskId = response.TaskId; + using var pollScope = BeginPollingScope("CreateVideo"); + var statusResult = await AsyncJobPoller.PollAsync( + fetchStatus: ct => FetchVideoStatusAsync(taskId, httpClient, ct), + classify: ClassifyVideoStatus, + extractSuccess: s => s, + extractFailure: s => new LLMCommunicationException( + $"MiniMax video generation failed: {s.BaseResp?.StatusMsg ?? "Unknown error"}"), + options: options, + logger: Logger, + cancellationToken: cancellationToken, + onProgress: async (poll, status) => { - // Wait before polling (skip first attempt) - if (attempt > 0) - { - await Task.Delay(pollingIntervalMs, cancellationToken); - - // Implement exponential backoff with jitter - pollingIntervalMs = Math.Min( - (int)(pollingIntervalMs * 1.5 + Random.Shared.Next(500)), - maxPollingIntervalMs); - } - - // Check status with retry on transient errors - var statusEndpoint = $"{_baseUrl}/v1/query/video_generation?task_id={response.TaskId}"; - var statusRequest = new HttpRequestMessage(HttpMethod.Get, statusEndpoint); - - HttpResponseMessage statusResponse; - string statusContent; - - try - { - statusResponse = await httpClient.SendAsync(statusRequest, cancellationToken); - statusContent = await statusResponse.Content.ReadAsStringAsync(); - - // Reset consecutive errors on success - consecutiveErrors = 0; - } - catch (HttpRequestException ex) - { - consecutiveErrors++; - Logger.LogWarning(ex, "Network error checking video status (attempt {Attempt}, consecutive errors: {ConsecutiveErrors})", - attempt + 1, consecutiveErrors); - - if (consecutiveErrors >= maxConsecutiveErrors) - { - throw new LLMCommunicationException($"Failed to check video status after {maxConsecutiveErrors} consecutive errors", ex); - } - - continue; - } - catch (TaskCanceledException ex) - { - Logger.LogWarning(ex, "Timeout checking video status (attempt {Attempt})", attempt + 1); - throw new LLMCommunicationException("Video status check timed out", ex); - } - - Logger.LogInformation("MiniMax video status check {Attempt}: {Status}", - attempt + 1, statusContent); - - if (!statusResponse.IsSuccessStatusCode) - { - // Handle specific error codes - if (statusResponse.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - { - Logger.LogWarning("Rate limited while checking video status, backing off"); - pollingIntervalMs = maxPollingIntervalMs; // Max out the interval - continue; - } - else if ((int)statusResponse.StatusCode >= 500) - { - // Server errors - retry with backoff - consecutiveErrors++; - Logger.LogWarning("Server error checking video status: {StatusCode} - {Response}", - statusResponse.StatusCode, statusContent); - - if (consecutiveErrors >= maxConsecutiveErrors) - { - throw new LLMCommunicationException($"Server error persisted after {maxConsecutiveErrors} attempts: {statusResponse.StatusCode}"); - } - continue; - } - else - { - // Client errors - don't retry - throw new LLMCommunicationException($"Client error checking video status: {statusResponse.StatusCode} - {statusContent}"); - } - } - - // Parse status response - MiniMaxVideoStatusResponse statusResult; - try + if (_progressCallback is null || poll.State != JobState.InProgress) { - statusResult = JsonSerializer.Deserialize(statusContent, - new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - })!; + return; } - catch (Exception ex) + var pct = status.Status switch { - Logger.LogError(ex, "Error deserializing status response: {Response}", statusContent); - continue; - } - - // Check for MiniMax error response in status - if (statusResult.BaseResp is { } statusBaseResp && statusBaseResp.StatusCode != 0) - { - // Handle specific MiniMax error codes - var errorMsg = $"MiniMax error {statusBaseResp.StatusCode}: {statusBaseResp.StatusMsg}"; - - switch (statusBaseResp.StatusCode) - { - case 1002: // Invalid API key - case 1004: // Authentication failed - throw new UnauthorizedAccessException(errorMsg); - - case 1008: // Quota exceeded - case 1013: // Rate limit exceeded - Logger.LogWarning("MiniMax quota/rate limit error: {Error}", errorMsg); - // Continue polling as the task might still complete - pollingIntervalMs = maxPollingIntervalMs; - continue; - - case 2013: // Content policy violation - throw new LLMCommunicationException($"Content policy violation: {statusBaseResp.StatusMsg}"); - - default: - if (statusBaseResp.StatusCode >= 2000) - { - // Application errors - task has failed - throw new LLMCommunicationException(errorMsg); - } - else - { - // System errors - might be transient - consecutiveErrors++; - Logger.LogWarning("MiniMax system error: {Error}", errorMsg); - - if (consecutiveErrors >= maxConsecutiveErrors) - { - throw new LLMCommunicationException($"MiniMax error persisted: {errorMsg}"); - } - continue; - } - } - } - - // Check if completed - if (statusResult.Status == "Success" && !string.IsNullOrEmpty(statusResult.FileId)) - { - Logger.LogInformation("MiniMax video generation completed: FileId={FileId}", statusResult.FileId); - - // For MiniMax, we need to fetch the video file using the file_id - // The video URL is constructed from the file_id - var videoUrl = $"https://api.minimax.io/v1/files/retrieve?file_id={statusResult.FileId}"; - - // Convert to standard response format - var videoData = new List - { - new VideoData - { - Url = videoUrl, - Metadata = new VideoMetadata - { - Width = statusResult.VideoWidth, - Height = statusResult.VideoHeight, - Duration = request.Duration ?? 6, - Fps = request.Fps ?? 30, - Format = "mp4", - MimeType = "video/mp4" - } - } - }; - - // Handle response format conversion if needed - if (request.ResponseFormat == "b64_json") - { - try - { - Logger.LogInformation("Downloading video for base64 conversion: {Url}", statusResult.Video?.Url); - using var videoResponse = await httpClient.GetAsync(statusResult.Video?.Url ?? string.Empty, cancellationToken); - if (videoResponse.IsSuccessStatusCode) - { - var videoBytes = await videoResponse.Content.ReadAsByteArrayAsync(cancellationToken); - videoData[0].B64Json = Convert.ToBase64String(videoBytes); - videoData[0].Url = null; - if (videoData[0].Metadata != null) - { - videoData[0].Metadata!.FileSizeBytes = videoBytes.Length; - } - } - else - { - Logger.LogWarning("Failed to download video from {Url}: {Status}", - statusResult.Video?.Url, videoResponse.StatusCode); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "Error downloading video from {Url}", statusResult.Video?.Url); - } - } - - var videoDuration = statusResult.Video?.Duration ?? request.Duration ?? 6; - var estimatedCost = EstimateVideoGenerationCost((int)videoDuration, request.Size ?? "1280x720"); - - return new VideoGenerationResponse - { - Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - Data = videoData, - Model = request.Model ?? "video-01", - Usage = new VideoGenerationUsage - { - VideosGenerated = 1, - TotalDurationSeconds = videoDuration, - EstimatedCost = estimatedCost - } - }; - } - else if (statusResult.Status == "Failed") + "Preparing" => 10, + "Queueing" => 20, + "Pending" => 30, + "Processing" => CalculateProcessingProgress(poll.AttemptCount, approxMaxAttempts), + _ => 0, + }; + await _progressCallback(taskId, status.Status ?? "unknown", pct); + }, + operationName: $"MiniMax video generation {taskId}", + instrumentation: pollScope); + + Logger.LogInformation("MiniMax video generation completed: FileId={FileId}", statusResult.FileId); + + var videoUrl = $"https://api.minimax.io/v1/files/retrieve?file_id={statusResult.FileId}"; + var videoData = new List + { + new VideoData + { + Url = videoUrl, + Metadata = new VideoMetadata { - throw new LLMCommunicationException($"MiniMax video generation failed: {statusResult.BaseResp?.StatusMsg ?? "Unknown error"}"); - } - else if (statusResult.Status == "Processing" || statusResult.Status == "Pending" || - statusResult.Status == "Preparing" || statusResult.Status == "Queueing") + Width = statusResult.VideoWidth, + Height = statusResult.VideoHeight, + Duration = request.Duration ?? 6, + Fps = request.Fps ?? 30, + Format = "mp4", + MimeType = "video/mp4", + }, + }, + }; + + if (request.ResponseFormat == "b64_json") + { + try + { + Logger.LogInformation("Downloading video for base64 conversion: {Url}", statusResult.Video?.Url); + using var videoResponse = await httpClient.GetAsync(statusResult.Video?.Url ?? string.Empty, cancellationToken); + if (videoResponse.IsSuccessStatusCode) { - Logger.LogDebug("MiniMax video generation still in progress: {Status}", statusResult.Status); - - // Report progress via callback if available - if (_progressCallback != null) + var videoBytes = await videoResponse.Content.ReadAsByteArrayAsync(cancellationToken); + videoData[0].B64Json = Convert.ToBase64String(videoBytes); + videoData[0].Url = null; + if (videoData[0].Metadata != null) { - // Map status to progress percentage - var progressPercentage = statusResult.Status switch - { - "Preparing" => 10, - "Queueing" => 20, - "Pending" => 30, - "Processing" => CalculateProcessingProgress(attempt, maxPollingAttempts), - _ => 0 - }; - - try - { - await _progressCallback(response.TaskId, statusResult.Status, progressPercentage); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Error calling progress callback for task {TaskId}", response.TaskId); - } + videoData[0].Metadata!.FileSizeBytes = videoBytes.Length; } - - // Continue polling } else { - Logger.LogWarning("Unknown MiniMax video status: {Status}", statusResult.Status); - // Continue polling for unknown statuses + Logger.LogWarning("Failed to download video from {Url}: {Status}", + statusResult.Video?.Url, videoResponse.StatusCode); } } - - throw new LLMCommunicationException("MiniMax video generation timed out after 10 minutes"); + catch (Exception ex) + { + Logger.LogError(ex, "Error downloading video from {Url}", statusResult.Video?.Url); + } } - - // Should not reach here for video generation as it's always async - throw new LLMCommunicationException("MiniMax video generation did not return a task ID"); + + var videoDuration = statusResult.Video?.Duration ?? request.Duration ?? 6; + var estimatedCost = EstimateVideoGenerationCost((int)videoDuration, request.Size ?? "1280x720"); + + return new VideoGenerationResponse + { + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Data = videoData, + Model = request.Model ?? "video-01", + Usage = new VideoGenerationUsage + { + VideosGenerated = 1, + TotalDurationSeconds = videoDuration, + EstimatedCost = estimatedCost, + }, + }; }, "CreateVideo", cancellationToken); } + private static int ResolveVideoPollingTimeoutSeconds() + { + const int defaultSeconds = 600; + var envTimeout = Environment.GetEnvironmentVariable("CONDUITLLM__TIMEOUTS__VIDEO_POLLING__SECONDS"); + if (!string.IsNullOrEmpty(envTimeout) && int.TryParse(envTimeout, out var parsed) && parsed > 0) + { + return parsed; + } + return defaultSeconds; + } + + private async Task FetchVideoStatusAsync( + string taskId, + HttpClient httpClient, + CancellationToken cancellationToken) + { + var endpoint = $"{_baseUrl}/v1/query/video_generation?task_id={taskId}"; + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + using var response = await httpClient.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + Logger.LogInformation("MiniMax video status check: {Status}", content); + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + Logger.LogWarning("Rate limited while checking video status, backing off"); + return new MiniMaxVideoStatusResponse + { + BaseResp = new BaseResponse { StatusCode = 1013, StatusMsg = "HTTP 429 rate-limited" }, + }; + } + if ((int)response.StatusCode >= 500) + { + Logger.LogWarning("Server error checking video status: {StatusCode} - {Response}", + response.StatusCode, content); + throw new HttpRequestException( + $"Server error checking video status: {response.StatusCode}"); + } + if (!response.IsSuccessStatusCode) + { + throw new LLMCommunicationException( + $"Client error checking video status: {response.StatusCode} - {content}"); + } + + MiniMaxVideoStatusResponse? parsed; + try + { + parsed = JsonSerializer.Deserialize(content, DefaultJsonOptions); + } + catch (JsonException ex) + { + Logger.LogError(ex, "Error deserializing status response: {Response}", content); + throw new HttpRequestException("Failed to deserialize MiniMax video status response", ex); + } + if (parsed is null) + { + throw new HttpRequestException("MiniMax returned null video status"); + } + return parsed; + } + + private static JobState ClassifyVideoStatus(MiniMaxVideoStatusResponse status) + { + if (status.BaseResp is { } baseResp && baseResp.StatusCode != 0) + { + var errorMsg = $"MiniMax error {baseResp.StatusCode}: {baseResp.StatusMsg}"; + switch (baseResp.StatusCode) + { + case 1002: + case 1004: + throw new UnauthorizedAccessException(errorMsg); + + case 1008: + case 1013: + return JobState.RateLimited; + + case 2013: + throw new LLMCommunicationException($"Content policy violation: {baseResp.StatusMsg}"); + + default: + if (baseResp.StatusCode >= 2000) + { + throw new LLMCommunicationException(errorMsg); + } + return JobState.TransientError; + } + } + + return status.Status switch + { + "Success" when !string.IsNullOrEmpty(status.FileId) => JobState.Succeeded, + "Failed" => JobState.Failed, + _ => JobState.InProgress, + }; + } + /// /// Calculates progress percentage for processing status based on polling attempts. /// @@ -457,22 +388,20 @@ public static decimal EstimateVideoGenerationCost(int duration, string resolutio /// /// Optional API key to override the one in credentials. /// A configured HttpClient instance for video generation. + /// Thrown when IHttpClientFactory is not available. protected virtual HttpClient CreateVideoHttpClient(string? apiKey = null) { - HttpClient client; - - // Use the factory if available (for testing), otherwise create new client - if (HttpClientFactory != null) - { - client = HttpClientFactory.CreateClient($"{ProviderName}VideoClient"); - } - else + if (HttpClientFactory == null) { - // For video generation, create a new HttpClient without using the factory - // This ensures no timeout policies are applied by HttpClientFactory in production - client = new HttpClient(); + throw new InvalidOperationException( + $"IHttpClientFactory is required for {ProviderName} video client but was not injected. " + + "Ensure IHttpClientFactory is registered in the dependency injection container. " + + "Creating HttpClient instances directly can cause socket exhaustion under load."); } - + + // Use a dedicated named client for video operations (should be configured without aggressive timeout policies) + var client = HttpClientFactory.CreateClient($"{ProviderName}VideoClient"); + string effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey!; if (string.IsNullOrWhiteSpace(effectiveApiKey)) { @@ -484,12 +413,13 @@ protected virtual HttpClient CreateVideoHttpClient(string? apiKey = null) client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); client.DefaultRequestHeaders.Add("User-Agent", "ConduitLLM"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", effectiveApiKey); - - // Set a very long timeout for video generation (1 hour) - client.Timeout = TimeSpan.FromHours(1); - - Logger.LogInformation("Created video HTTP client with 1-hour timeout and no Polly policies (bypassing factory: {BypassFactory})", HttpClientFactory == null); - + + // Use large file download timeout for video files + client.Timeout = LargeFileDownloadTimeout; + + Logger.LogInformation("Created video HTTP client with {Timeout} timeout via IHttpClientFactory", + LargeFileDownloadTimeout); + return client; } } diff --git a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.cs b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.cs index dd22039d7..fc384f6c0 100644 --- a/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/MiniMax/MiniMaxClient.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + using ConduitLLM.Configuration; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; @@ -15,6 +17,15 @@ public partial class MiniMaxClient : BaseLLMClient, IAuthenticationVerifiable private readonly string _baseUrl; private Func? _progressCallback; + /// + /// MiniMax chat API returns snake_case properties โ€” needs case-insensitive deserialization. + /// + private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + /// /// Initializes a new instance of the class. /// @@ -39,12 +50,13 @@ public MiniMaxClient( /// protected override void ConfigureHttpClient(HttpClient client, string apiKey) { - client.DefaultRequestHeaders.Clear(); - client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); - client.DefaultRequestHeaders.Add("User-Agent", "ConduitLLM"); - // Add Accept header for SSE streaming - client.DefaultRequestHeaders.Add("Accept", "text/event-stream"); - client.Timeout = TimeSpan.FromMinutes(10); // Long timeout for video processing + base.ConfigureHttpClient(client, apiKey); + // Override Accept header for SSE streaming (base sets application/json) + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add( + new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + // Use video generation timeout since MiniMax supports video + client.Timeout = VideoGenerationTimeout; } /// diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.Authentication.cs b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.Authentication.cs index 69c13435b..21c18b3ae 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.Authentication.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.Authentication.cs @@ -1,4 +1,6 @@ +using ConduitLLM.Configuration; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Providers.Configuration; using ConduitLLM.Providers.Helpers; using Microsoft.Extensions.Logging; @@ -20,7 +22,7 @@ public override async Task VerifyAuthenticationAsync( { var startTime = DateTime.UtcNow; var effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey; - + if (string.IsNullOrWhiteSpace(effectiveApiKey)) { return AuthenticationResult.Failure( @@ -31,7 +33,7 @@ public override async Task VerifyAuthenticationAsync( try { using var client = CreateHttpClient(effectiveApiKey); - + // Override base URL if provided if (!string.IsNullOrWhiteSpace(baseUrl)) { @@ -54,7 +56,7 @@ public override async Task VerifyAuthenticationAsync( Logger.LogDebug("Testing authentication with endpoint: {Endpoint}", endpoint); - var response = await client.GetAsync(endpoint, cancellationToken); + using var response = await client.GetAsync(endpoint, cancellationToken); var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; Logger.LogInformation("{Provider} auth check returned status {StatusCode}", ProviderName, response.StatusCode); @@ -68,7 +70,7 @@ public override async Task VerifyAuthenticationAsync( // Handle specific error cases var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { Logger.LogWarning("{Provider} authentication failed: {Response}", ProviderName, responseContent); @@ -116,11 +118,14 @@ public override async Task VerifyAuthenticationAsync( /// public override string GetHealthCheckUrl(string? baseUrl = null) { - var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) - ? baseUrl.TrimEnd('/') - : (!string.IsNullOrWhiteSpace(Provider.BaseUrl) - ? Provider.BaseUrl.TrimEnd('/') - : Constants.Urls.DefaultOpenAIBaseUrl.TrimEnd('/')); + var defaultBaseUrl = ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.OpenAI) + ?? "https://api.openai.com/v1"; + + var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) + ? baseUrl.TrimEnd('/') + : (!string.IsNullOrWhiteSpace(Provider.BaseUrl) + ? Provider.BaseUrl.TrimEnd('/') + : defaultBaseUrl.TrimEnd('/')); if (_isAzure) { @@ -132,11 +137,12 @@ public override string GetHealthCheckUrl(string? baseUrl = null) } /// - /// Gets the default base URL for OpenAI. + /// Gets the default base URL for OpenAI from the configuration registry. /// protected override string GetDefaultBaseUrl() { - return Constants.Urls.DefaultOpenAIBaseUrl; + return ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.OpenAI) + ?? "https://api.openai.com/v1"; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.ErrorHandling.cs b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.ErrorHandling.cs index 46b17e2b4..01f726f89 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.ErrorHandling.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.ErrorHandling.cs @@ -1,4 +1,3 @@ -using System.Net; using ConduitLLM.Core.Models; using Microsoft.Extensions.Logging; @@ -10,54 +9,23 @@ namespace ConduitLLM.Providers.OpenAI public partial class OpenAIClient { /// - /// Refines error classification based on OpenAI-specific error patterns. + /// Refines error classification with OpenAI-specific logging. + /// Common patterns (quota, rate limit, model not found) are handled by BaseLLMClient. /// - /// The base error type determined from HTTP status code. - /// The response body containing error details. - /// The refined error type. protected override ProviderErrorType RefineErrorClassification( - ProviderErrorType baseType, + ProviderErrorType baseType, string? responseBody) { - // OpenAI often returns 403 for insufficient quota - if (baseType == ProviderErrorType.AccessForbidden && !string.IsNullOrEmpty(responseBody)) - { - var lowerBody = responseBody.ToLowerInvariant(); - - // Check for quota/billing related messages - if (lowerBody.Contains("insufficient_quota") || - lowerBody.Contains("exceeded your current quota") || - lowerBody.Contains("billing") || - lowerBody.Contains("payment") || - lowerBody.Contains("credit")) - { - Logger.LogWarning("OpenAI returned 403 for insufficient quota/billing issue"); - return ProviderErrorType.InsufficientBalance; - } - } - - // Check for rate limit in error message even if status isn't 429 - if (!string.IsNullOrEmpty(responseBody)) + var refined = base.RefineErrorClassification(baseType, responseBody); + + // Add OpenAI-specific logging for quota issues + if (refined == ProviderErrorType.InsufficientBalance && + baseType == ProviderErrorType.AccessForbidden) { - var lowerBody = responseBody.ToLowerInvariant(); - - if (lowerBody.Contains("rate limit") || - lowerBody.Contains("too many requests")) - { - return ProviderErrorType.RateLimitExceeded; - } - - // Model not found patterns - if (lowerBody.Contains("model") && - (lowerBody.Contains("not found") || - lowerBody.Contains("does not exist") || - lowerBody.Contains("invalid model"))) - { - return ProviderErrorType.ModelNotFound; - } + Logger.LogWarning("OpenAI returned 403 for insufficient quota/billing issue"); } - - return baseType; + + return refined; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.cs b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.cs index 95ca60777..00e9ed499 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIClient.cs @@ -1,9 +1,9 @@ -using System.Net.Http.Headers; - using ConduitLLM.Configuration; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Providers.Authentication; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; @@ -21,32 +21,30 @@ namespace ConduitLLM.Providers.OpenAI /// public partial class OpenAIClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient { - // Default API configuration constants + // API configuration constants private static class Constants { - public static class Urls - { - public const string DefaultOpenAIBaseUrl = "https://api.openai.com/v1"; - } - - // Azure API version is now hardcoded public const string AzureApiVersion = "2024-02-01"; public static class Endpoints { - public const string ChatCompletions = "/chat/completions"; public const string Models = "/models"; + public const string ChatCompletions = "/chat/completions"; public const string Embeddings = "/embeddings"; public const string ImageGenerations = "/images/generations"; - public const string AudioTranscriptions = "/audio/transcriptions"; - public const string AudioTranslations = "/audio/translations"; - public const string AudioSpeech = "/audio/speech"; } } private readonly bool _isAzure; private readonly IModelCapabilityService? _capabilityService; + /// + /// Gets the authentication strategy based on whether this is Azure or standard OpenAI. + /// Azure uses api-key header, standard OpenAI uses Bearer token. + /// + protected override IAuthenticationStrategy AuthenticationStrategy => + _isAzure ? ApiKeyHeaderStrategy.AzureInstance : BearerTokenStrategy.Instance; + /// /// Initializes a new instance of the OpenAIClient class. /// @@ -96,40 +94,20 @@ private static string DetermineBaseUrl(Provider provider, ProviderKeyCredential { // Use key credential base URL if specified, otherwise fall back to provider base URL var baseUrl = keyCredential.BaseUrl ?? provider.BaseUrl; - + // For Azure, we'll handle this specially in the endpoint methods if (providerName.Equals("azure", StringComparison.OrdinalIgnoreCase)) { return baseUrl ?? ""; } - // For standard OpenAI or compatible providers + // For standard OpenAI or compatible providers, use registry default baseUrl = string.IsNullOrWhiteSpace(baseUrl) - ? Constants.Urls.DefaultOpenAIBaseUrl + ? ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.OpenAI) : baseUrl; - - // Ensure consistent formatting - return baseUrl.TrimEnd('/'); - } - - /// - /// Configures the HTTP client with appropriate headers and settings. - /// - protected override void ConfigureHttpClient(HttpClient client, string apiKey) - { - client.DefaultRequestHeaders.Accept.Clear(); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - client.DefaultRequestHeaders.Add("User-Agent", "ConduitLLM"); - // Different authentication method for Azure vs. standard OpenAI - if (_isAzure) - { - client.DefaultRequestHeaders.Add("api-key", apiKey); - } - else - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - } + // Ensure consistent formatting + return baseUrl?.TrimEnd('/') ?? "https://api.openai.com/v1"; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIModels.cs b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIModels.cs index 158b5ac20..2f300a64c 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIModels.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAI/OpenAIModels.cs @@ -152,6 +152,13 @@ internal record OpenAIUsage [JsonPropertyName("reasoning_tokens")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ReasoningTokens { get; init; } + + /// + /// Captures provider-specific usage fields not explicitly modeled + /// (e.g., prompt_tokens_details, cache_creation_input_tokens, prompt_cache_hit_tokens). + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } } // --- Internal Models for Streaming Chunks --- diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Chat.cs b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Chat.cs index 0c496850f..5c2e2501e 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Chat.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Chat.cs @@ -47,10 +47,12 @@ public abstract partial class OpenAICompatibleClient var endpoint = GetChatCompletionEndpoint(); - // Log the actual request being sent - var requestJson = System.Text.Json.JsonSerializer.Serialize(openAiRequest, DefaultJsonOptions); - Logger.LogWarning("FINAL REQUEST JSON: {Json}", requestJson); - Logger.LogDebug("Sending chat completion request to {Provider} at {Endpoint}", ProviderName, endpoint); + if (Logger.IsEnabled(LogLevel.Debug)) + { + var requestJson = System.Text.Json.JsonSerializer.Serialize(openAiRequest, DefaultJsonOptions); + Logger.LogDebug("Sending chat completion request to {Provider} at {Endpoint}: {Json}", + ProviderName, endpoint, requestJson); + } // Use our common HTTP client helper to send the request var openAiResponse = await CoreUtils.HttpClientHelper.SendJsonRequestAsync( @@ -63,7 +65,9 @@ public abstract partial class OpenAICompatibleClient Logger, cancellationToken); - return MapFromOpenAIResponse(openAiResponse, request.Model); + var mapped = MapFromOpenAIResponse(openAiResponse, request.Model); + RecordUsage(mapped.Usage, "ChatCompletion"); + return mapped; }, "ChatCompletion", cancellationToken); } } diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Mapping.cs b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Mapping.cs index 02878cca3..c08ccd7d6 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Mapping.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Mapping.cs @@ -24,7 +24,7 @@ protected virtual object MapToOpenAIRequest(CoreModels.ChatCompletionRequest req { // Map tools if present List? openAiTools = null; - if (request.Tools != null && request.Tools.Count() > 0) + if (request.Tools != null && request.Tools.Any()) { openAiTools = request.Tools.Select(t => new { @@ -52,9 +52,11 @@ protected virtual object MapToOpenAIRequest(CoreModels.ChatCompletionRequest req return new OpenAIMessage { Role = m.Role, - Content = ProviderHelpers.ContentHelper.IsTextOnly(m.Content) - ? ProviderHelpers.ContentHelper.GetContentAsString(m.Content) - : MapMultimodalContent(m.Content), + Content = ProviderHelpers.ContentHelper.ShouldPreserveAsArray(m.Content) + ? PassThroughContentArray(m.Content) + : ProviderHelpers.ContentHelper.IsTextOnly(m.Content) + ? ProviderHelpers.ContentHelper.GetContentAsString(m.Content) + : MapMultimodalContent(m.Content), Name = m.Name, ToolCalls = m.ToolCalls?.Select(tc => new { @@ -114,27 +116,17 @@ protected virtual object MapToOpenAIRequest(CoreModels.ChatCompletionRequest req // Pass through any extension data (model-specific parameters) if (request.ExtensionData != null) { - Logger.LogWarning("ExtensionData has {Count} items", request.ExtensionData.Count); + Logger.LogDebug("Forwarding {Count} extension data parameters", request.ExtensionData.Count); foreach (var kvp in request.ExtensionData) { - Logger.LogWarning("ExtensionData contains: {Key} = {Value} (Type: {Type})", - kvp.Key, kvp.Value.ToString(), kvp.Value.ValueKind); - // Don't override standard parameters if (!openAiRequest.ContainsKey(kvp.Key)) { // Convert JsonElement to actual value for proper serialization - var converted = ConvertJsonElement(kvp.Value); - openAiRequest[kvp.Key] = converted; - Logger.LogWarning("Added to request: {Key} = {Value} (Type: {Type})", - kvp.Key, converted, converted?.GetType().Name ?? "null"); + openAiRequest[kvp.Key] = ConvertJsonElement(kvp.Value); } } } - else - { - Logger.LogWarning("ExtensionData is NULL"); - } return openAiRequest; } @@ -191,6 +183,74 @@ protected virtual object MapMultimodalContent(object? content) return contentParts; } + /// + /// Passes through content array elements preserving all properties (including cache_control). + /// + /// The content object which should be a JSON array + /// A list of dictionaries preserving all properties on each content block + protected virtual object PassThroughContentArray(object? content) + { + if (content == null) + return ""; + + if (content is System.Text.Json.JsonElement jsonElement && jsonElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + // Convert each array element to a dictionary preserving all properties + var contentParts = new List(); + foreach (var element in jsonElement.EnumerateArray()) + { + if (element.ValueKind == System.Text.Json.JsonValueKind.Object) + { + var dict = new Dictionary(); + foreach (var prop in element.EnumerateObject()) + { + dict[prop.Name] = ConvertJsonElement(prop.Value); + } + contentParts.Add(dict); + } + } + return contentParts.Count > 0 ? contentParts : (object)""; + } + + // Handle IEnumerable of dictionaries (from PromptCacheInjectionService) + if (content is IEnumerable contentList) + { + var parts = new List(); + foreach (var item in contentList) + { + if (item is IDictionary dictNullable) + { + parts.Add(dictNullable); + } + else if (item is IDictionary dictNonNull) + { + parts.Add(dictNonNull); + } + else + { + parts.Add(item); + } + } + if (parts.Count > 0) + return parts; + } + + // Fallback: try serialize/deserialize to preserve structure + try + { + var json = System.Text.Json.JsonSerializer.Serialize(content); + var list = System.Text.Json.JsonSerializer.Deserialize>>(json); + if (list != null && list.Count > 0) + return list; + } + catch + { + // Fall through to MapMultimodalContent + } + + return MapMultimodalContent(content); + } + /// /// Maps the OpenAI response to provider-agnostic format. /// @@ -249,13 +309,7 @@ protected virtual CoreModels.ChatCompletionResponse MapFromOpenAIResponse( Content = null } }).ToList() ?? new List(), - Usage = response.Usage != null ? new CoreModels.Usage - { - PromptTokens = response.Usage.PromptTokens, - CompletionTokens = response.Usage.CompletionTokens, - TotalTokens = response.Usage.TotalTokens, - ReasoningTokens = response.Usage.ReasoningTokens - } : null, + Usage = response.Usage != null ? MapUsageFromOpenAI(response.Usage) : null, SystemFingerprint = response.SystemFingerprint, Seed = response.Seed, OriginalModelAlias = originalModelAlias @@ -268,6 +322,59 @@ protected virtual CoreModels.ChatCompletionResponse MapFromOpenAIResponse( } } + /// + /// Maps an OpenAIUsage record to the provider-agnostic Usage model, + /// extracting cached token counts from provider-specific extension data. + /// + /// The OpenAI usage data. + /// A provider-agnostic Usage object with cached token fields populated. + private static CoreModels.Usage MapUsageFromOpenAI(OpenAIUsage openAiUsage) + { + var usage = new CoreModels.Usage + { + PromptTokens = openAiUsage.PromptTokens, + CompletionTokens = openAiUsage.CompletionTokens, + TotalTokens = openAiUsage.TotalTokens, + ReasoningTokens = openAiUsage.ReasoningTokens + }; + + if (openAiUsage.ExtensionData == null) + return usage; + + // OpenAI format: usage.prompt_tokens_details.cached_tokens + if (openAiUsage.ExtensionData.TryGetValue("prompt_tokens_details", out var promptDetails) && + promptDetails.ValueKind == System.Text.Json.JsonValueKind.Object) + { + if (promptDetails.TryGetProperty("cached_tokens", out var cachedTokens) && + cachedTokens.TryGetInt32(out var cached)) + { + usage.CachedInputTokens = cached; + } + } + + // Anthropic format: usage.cache_read_input_tokens / usage.cache_creation_input_tokens + if (openAiUsage.ExtensionData.TryGetValue("cache_read_input_tokens", out var cacheRead) && + cacheRead.TryGetInt32(out var cacheReadCount)) + { + usage.CachedInputTokens = cacheReadCount; + } + + if (openAiUsage.ExtensionData.TryGetValue("cache_creation_input_tokens", out var cacheWrite) && + cacheWrite.TryGetInt32(out var cacheWriteCount)) + { + usage.CachedWriteTokens = cacheWriteCount; + } + + // Deepseek format: usage.prompt_cache_hit_tokens / usage.prompt_cache_miss_tokens + if (openAiUsage.ExtensionData.TryGetValue("prompt_cache_hit_tokens", out var cacheHit) && + cacheHit.TryGetInt32(out var cacheHitCount)) + { + usage.CachedInputTokens = cacheHitCount; + } + + return usage; + } + /// /// Creates an empty chat completion response for error cases. /// @@ -287,43 +394,50 @@ private CoreModels.ChatCompletionResponse CreateEmptyResponse(string? originalMo } /// - /// Converts a JsonElement to its actual .NET value for proper serialization. + /// Post-processes a deserialized Usage object to extract cached token counts + /// from provider-specific extension data fields. + /// Call this after deserializing a Usage object from provider JSON. /// - /// The JsonElement to convert. - /// The converted value as a proper .NET type. - private static object? ConvertJsonElement(System.Text.Json.JsonElement element) + /// The deserialized Usage object to post-process. + internal static void ExtractCachedTokensFromExtensionData(CoreModels.Usage? usage) { - switch (element.ValueKind) + if (usage?.ExtensionData == null) + return; + + // OpenAI format: prompt_tokens_details.cached_tokens + if (usage.ExtensionData.TryGetValue("prompt_tokens_details", out var promptDetails) && + promptDetails.ValueKind == System.Text.Json.JsonValueKind.Object) { - case System.Text.Json.JsonValueKind.String: - return element.GetString(); - case System.Text.Json.JsonValueKind.Number: - if (element.TryGetInt32(out var intValue)) - return intValue; - if (element.TryGetInt64(out var longValue)) - return longValue; - return element.GetDouble(); - case System.Text.Json.JsonValueKind.True: - return true; - case System.Text.Json.JsonValueKind.False: - return false; - case System.Text.Json.JsonValueKind.Null: - return null; - case System.Text.Json.JsonValueKind.Array: - return element.EnumerateArray() - .Select(e => ConvertJsonElement(e)) - .ToList(); - case System.Text.Json.JsonValueKind.Object: - var dict = new Dictionary(); - foreach (var property in element.EnumerateObject()) - { - dict[property.Name] = ConvertJsonElement(property.Value); - } - return dict; - default: - return element.ToString(); + if (promptDetails.TryGetProperty("cached_tokens", out var cachedTokens) && + cachedTokens.TryGetInt32(out var cached)) + { + usage.CachedInputTokens ??= cached; + } + } + + // Anthropic format: cache_read_input_tokens / cache_creation_input_tokens + if (usage.ExtensionData.TryGetValue("cache_read_input_tokens", out var cacheRead) && + cacheRead.TryGetInt32(out var cacheReadCount)) + { + usage.CachedInputTokens ??= cacheReadCount; + } + + if (usage.ExtensionData.TryGetValue("cache_creation_input_tokens", out var cacheWrite) && + cacheWrite.TryGetInt32(out var cacheWriteCount)) + { + usage.CachedWriteTokens ??= cacheWriteCount; + } + + // Deepseek format: prompt_cache_hit_tokens + if (usage.ExtensionData.TryGetValue("prompt_cache_hit_tokens", out var cacheHit) && + cacheHit.TryGetInt32(out var cacheHitCount)) + { + usage.CachedInputTokens ??= cacheHitCount; } } + private static object? ConvertJsonElement(System.Text.Json.JsonElement element) => + ProviderHelpers.JsonElementConverter.ConvertJsonElement(element); + } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Streaming.cs b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Streaming.cs index 953398566..7f23dab93 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Streaming.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Streaming.cs @@ -54,111 +54,115 @@ public abstract partial class OpenAICompatibleClient } } + /// + /// Transforms the raw JSON of a streaming chunk before deserialization. + /// Override in subclasses to perform provider-specific JSON transformations + /// (e.g., extracting usage data from vendor-specific fields). + /// + /// The raw JSON element from the SSE stream. + /// The JSON string to deserialize into a ChatCompletionChunk. + protected virtual string TransformChunkJson(JsonElement chunk) + => chunk.GetRawText(); + /// /// Streams chunks progressively without buffering them into a list /// - private async IAsyncEnumerable StreamChunksProgressivelyAsync( + protected virtual async IAsyncEnumerable StreamChunksProgressivelyAsync( CoreModels.ChatCompletionRequest request, string? apiKey = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + using var logScope = BeginProviderLogScope("StreamChatCompletion"); + var instrumentation = BeginStreamingScope("StreamChatCompletion"); HttpClient? client = null; HttpResponseMessage? response = null; - + try { - client = CreateHttpClient(apiKey); - var openAiRequest = PrepareStreamingRequest(request); - var endpoint = GetChatCompletionEndpoint(); - - Logger.LogDebug("Sending streaming chat completion request to {Provider} at {Endpoint}", ProviderName, endpoint); + try + { + client = CreateHttpClient(apiKey); + var openAiRequest = PrepareStreamingRequest(request); + var endpoint = GetChatCompletionEndpoint(); - response = await SendStreamingRequestAsync(client, endpoint, openAiRequest, apiKey, cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Process the error with enhanced error extraction - var enhancedErrorMessage = ExtractEnhancedErrorMessage(ex); - Logger.LogError(ex, "Error in streaming chat completion from {Provider}: {Message}", ProviderName, enhancedErrorMessage); + Logger.LogDebug("Sending streaming chat completion request to {Provider} at {Endpoint}", ProviderName, endpoint); - var error = CoreUtils.ExceptionHandler.HandleLlmException(ex, Logger, ProviderName, request.Model ?? ProviderModelId); - - // Clean up resources - response?.Dispose(); - client?.Dispose(); - - throw error; - } - - // If we get here, we have a response to stream - if (response != null) - { - // Stream chunks progressively using StreamHelper - use JsonElement for raw passthrough - await foreach (var chunk in CoreUtils.StreamHelper.ProcessSseStreamAsync( - response, Logger, DefaultJsonOptions, cancellationToken)) + response = await SendStreamingRequestAsync(client, endpoint, openAiRequest, apiKey, cancellationToken); + } + catch (OperationCanceledException) { - if (cancellationToken.IsCancellationRequested) - { - response.Dispose(); - client?.Dispose(); - yield break; - } + instrumentation.RecordFailure(nameof(OperationCanceledException)); + response?.Dispose(); + client?.Dispose(); + throw; + } + catch (Exception ex) + { + // Process the error with enhanced error extraction + var enhancedErrorMessage = ExtractEnhancedErrorMessage(ex); + Logger.LogError(ex, "Error in streaming chat completion from {Provider}: {Message}", ProviderName, enhancedErrorMessage); + + var error = CoreUtils.ExceptionHandler.HandleLlmException(ex, Logger, ProviderName, request.Model ?? ProviderModelId); + + instrumentation.RecordFailure(error.GetType().Name); + + // Clean up resources + response?.Dispose(); + client?.Dispose(); + + throw error; + } - // Deserialize the raw JSON directly to our chunk type, preserving ALL fields - var chunkJson = chunk.GetRawText(); - var mappedChunk = System.Text.Json.JsonSerializer.Deserialize( - chunkJson, DefaultJsonOptions); - - if (mappedChunk != null) + // If we get here, we have a response to stream + if (response != null) + { + bool reportedUsage = false; + // Stream chunks progressively using StreamHelper - use JsonElement for raw passthrough + await foreach (var chunk in CoreUtils.StreamHelper.ProcessSseStreamAsync( + response, Logger, DefaultJsonOptions, cancellationToken)) { - // Preserve the original model alias if provided - if (!string.IsNullOrEmpty(request.Model)) + if (cancellationToken.IsCancellationRequested) { - mappedChunk.Model = request.Model; - mappedChunk.OriginalModelAlias = request.Model; + yield break; } - - yield return mappedChunk; - } - } - - // Clean up after successful streaming - response.Dispose(); - client?.Dispose(); - } - } - /// - /// Helper method to fetch all stream chunks without yielding in a try block - /// - private async Task> FetchStreamChunksAsync( - CoreModels.ChatCompletionRequest request, - string? apiKey = null, - CancellationToken cancellationToken = default) - { - var chunks = new List(); + // Transform the raw JSON (allows subclasses to inject provider-specific processing) + var chunkJson = TransformChunkJson(chunk); + var mappedChunk = System.Text.Json.JsonSerializer.Deserialize( + chunkJson, DefaultJsonOptions); - try - { - using var client = CreateHttpClient(apiKey); - var openAiRequest = PrepareStreamingRequest(request); - var endpoint = GetChatCompletionEndpoint(); + if (mappedChunk != null) + { + // Preserve the original model alias if provided + if (!string.IsNullOrEmpty(request.Model)) + { + mappedChunk.Model = request.Model; + mappedChunk.OriginalModelAlias = request.Model; + } + + // Extract cached token counts from provider-specific extension data + ExtractCachedTokensFromExtensionData(mappedChunk.Usage); - Logger.LogDebug("Sending streaming chat completion request to {Provider} at {Endpoint}", ProviderName, endpoint); + instrumentation.RecordChunk(); - var response = await SendStreamingRequestAsync(client, endpoint, openAiRequest, apiKey, cancellationToken); - chunks = await ProcessStreamingResponseAsync(response, request.Model, cancellationToken); + // Many providers emit usage in a final chunk when stream_options.include_usage=true. + // Record once per stream so providers aren't double-counted. + if (!reportedUsage && mappedChunk.Usage != null) + { + RecordUsage(mappedChunk.Usage, "StreamChatCompletion"); + reportedUsage = true; + } - return chunks; + yield return mappedChunk; + } + } + } } - catch (Exception ex) when (ex is not OperationCanceledException) + finally { - // Process the error with enhanced error extraction - var enhancedErrorMessage = ExtractEnhancedErrorMessage(ex); - Logger.LogError(ex, "Error in streaming chat completion from {Provider}: {Message}", ProviderName, enhancedErrorMessage); - - var error = CoreUtils.ExceptionHandler.HandleLlmException(ex, Logger, ProviderName, request.Model ?? ProviderModelId); - throw error; + response?.Dispose(); + client?.Dispose(); + instrumentation.Dispose(); } } @@ -252,33 +256,5 @@ private async Task SendStreamingRequestAsync( cancellationToken); } - /// - /// Processes a streaming response and returns a list of chat completion chunks - /// - /// The HTTP response message - /// The original model alias from the request - /// A token to monitor for cancellation requests - /// A list of chat completion chunks - private async Task> ProcessStreamingResponseAsync( - HttpResponseMessage response, - string? originalModelAlias, - CancellationToken cancellationToken) - { - var chunks = new List(); - - // Use StreamHelper to process the SSE stream - await foreach (var chunk in CoreUtils.StreamHelper.ProcessSseStreamAsync( - response, Logger, DefaultJsonOptions, cancellationToken)) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - chunks.Add(MapFromOpenAIChunk(chunk, originalModelAlias)); - } - - return chunks; - } } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Utilities.cs b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Utilities.cs index 8c3e7d4af..6d81b712c 100644 --- a/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Utilities.cs +++ b/Shared/ConduitLLM.Providers/Providers/OpenAICompatible/OpenAICompatibleClient.Utilities.cs @@ -1,7 +1,3 @@ -using System.Text.Json; - -using Microsoft.Extensions.Logging; - using CoreModels = ConduitLLM.Core.Models; namespace ConduitLLM.Providers.OpenAICompatible @@ -11,460 +7,6 @@ namespace ConduitLLM.Providers.OpenAICompatible /// public abstract partial class OpenAICompatibleClient { - /// - /// Adds optional properties to a chat completion response if they exist in the provider response. - /// - /// The chat completion response to enhance. - /// The dynamic provider response. - /// The enhanced chat completion response. - private CoreModels.ChatCompletionResponse AddOptionalResponseProperties( - CoreModels.ChatCompletionResponse response, - dynamic providerResponse) - { - // Try to add SystemFingerprint - try - { - var hasSysFp = HasProperty(providerResponse, "SystemFingerprint"); - if (hasSysFp) - { - response.SystemFingerprint = providerResponse.SystemFingerprint; - } - } - catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - // Property doesn't exist, which is OK for most providers - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Error adding SystemFingerprint property: {Message}", ex.Message); - } - - // Try to add Seed - try - { - var hasSeed = HasProperty(providerResponse, "Seed"); - if (hasSeed) - { - response.Seed = providerResponse.Seed; - } - } - catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) - { - // Property doesn't exist, which is OK for most providers - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Error adding Seed property: {Message}", ex.Message); - } - - return response; - } - - /// - /// Tries to get a property value from a dynamic object, returning a default value if not found. - /// - /// The type of value to return. - /// The dynamic object to get the property from. - /// The name of the property to get. - /// The default value to return if the property is not found. - /// The property value if found, or the default value if not. - private T TryGetProperty(dynamic obj, string propertyName, T defaultValue) - { - try - { - var hasProperty = HasProperty(obj, propertyName); - if (hasProperty) - { - // Try to get the property using reflection - var property = obj.GetType().GetProperty(propertyName); - if (property != null) - { - return (T)property.GetValue(obj, null); - } - - // If reflection fails, try dynamic access - if (obj is IDictionary dictObj && dictObj.TryGetValue(propertyName, out var dictValue)) - { - return (T)dictValue; - } - } - } - catch - { - // Property doesn't exist or couldn't be accessed - // This is expected behavior for optional properties, so we use Debug level - // Suppress logging for now since we're in a dynamic context - // This is expected behavior when optional properties don't exist - } - - return defaultValue; - } - - /// - /// Checks if a dynamic object has a specific property. - /// - /// The dynamic object to check. - /// The name of the property to check for. - /// True if the property exists, false otherwise. - private bool HasProperty(dynamic obj, string propertyName) - { - try - { - // Try to access the property using reflection - var result = obj.GetType().GetProperty(propertyName) != null; - return result; - } - catch - { - try - { - // Alternatively, try to convert to JSON and check if the property exists - var jsonString = System.Text.Json.JsonSerializer.Serialize(obj); - var jsonDoc = System.Text.Json.JsonDocument.Parse(jsonString); - System.Text.Json.JsonElement outValue; - return jsonDoc.RootElement.TryGetProperty(propertyName, out outValue); - } - catch - { - return false; - } - } - } - - /// - /// Maps the OpenAI streaming chunk to provider-agnostic format. - /// - /// The chunk from the OpenAI streaming API. - /// The original model alias from the request. - /// A provider-agnostic chat completion chunk. - /// - /// This method maps the OpenAI-formatted streaming chunk to the generic format used by the application. - /// Derived classes can override this method to provide custom mapping. - /// - protected virtual CoreModels.ChatCompletionChunk MapFromOpenAIChunk( - object chunkObj, - string? originalModelAlias) - { - // Cast using dynamic to avoid multiple type-specific methods - dynamic chunk = chunkObj; - - return new CoreModels.ChatCompletionChunk - { - Id = chunk.Id, - Object = chunk.Object, - Created = chunk.Created, - Model = originalModelAlias ?? chunk.Model, // Use original alias if provided - SystemFingerprint = chunk.SystemFingerprint, - Choices = MapDynamicStreamingChoices(chunk.Choices), - OriginalModelAlias = originalModelAlias, - // Map usage data if present (typically in the final chunk) - Usage = null // TODO: Implement usage mapping for streaming - }; - } - - /// - /// Maps dynamic choices from a response to strongly-typed Choice objects. - /// - /// The dynamic choices collection from response. - /// A list of strongly-typed Choice objects. - private List MapDynamicChoices(dynamic dynamicChoices) - { - var choices = new List(); - - // Handle null choices - if (dynamicChoices == null) - { - return choices; - } - - try - { - foreach (var choice in dynamicChoices) - { - try - { - var mappedChoice = MapSingleChoice(choice); - choices.Add(mappedChoice); - } - catch (Exception ex) - { - // Log but don't fail on individual choice processing - Logger.LogWarning("Error processing choice: {Error}", ex.Message); - } - } - } - catch (Exception ex) - { - // Log and return whatever choices we managed to process - Logger.LogError(ex, "Error mapping choices"); - } - - return choices; - } - - /// - /// Maps a single dynamic choice to a strongly-typed Choice object. - /// - /// The dynamic choice to map. - /// A strongly-typed Choice object. - private CoreModels.Choice MapSingleChoice(dynamic choice) - { - var mappedChoice = new CoreModels.Choice - { - Index = choice.Index, - FinishReason = choice.FinishReason, - Message = new CoreModels.Message - { - Role = choice.Message.Role, - Content = choice.Message.Content - } - }; - - // Handle tool calls if present - if (choice.Message.ToolCalls != null) - { - mappedChoice.Message.ToolCalls = MapResponseToolCalls(choice.Message.ToolCalls); - } - - // Handle tool_call_id if present (for tool response messages) - if (choice.Message.ToolCallId != null) - { - mappedChoice.Message.ToolCallId = choice.Message.ToolCallId?.ToString(); - } - - return mappedChoice; - } - - /// - /// Maps dynamic tool calls from a response to strongly-typed ToolCall objects. - /// - /// The dynamic tool calls to map. - /// A list of strongly-typed ToolCall objects. - private List MapResponseToolCalls(dynamic toolCalls) - { - var mappedToolCalls = new List(); - - foreach (var toolCall in toolCalls) - { - try - { - var mappedToolCall = MapSingleResponseToolCall(toolCall); - mappedToolCalls.Add(mappedToolCall); - } - catch (Exception ex) - { - // Log but continue with other tool calls - Logger.LogWarning(ex, "Error mapping tool call"); - } - } - - return mappedToolCalls; - } - - /// - /// Maps a single dynamic tool call from a response to a strongly-typed ToolCall object. - /// - /// The dynamic tool call to map. - /// A strongly-typed ToolCall object. - private CoreModels.ToolCall MapSingleResponseToolCall(dynamic toolCall) - { - return new CoreModels.ToolCall - { - Id = toolCall.id?.ToString() ?? Guid.NewGuid().ToString(), - Type = toolCall.type?.ToString() ?? "function", - Function = new CoreModels.FunctionCall - { - Name = toolCall.function?.name?.ToString() ?? "unknown", - Arguments = toolCall.function?.arguments?.ToString() ?? "{}" - } - }; - } - - /// - /// Maps dynamic streaming choices to strongly-typed StreamingChoice objects. - /// - /// The dynamic streaming choices collection. - /// A list of strongly-typed StreamingChoice objects. - private List MapDynamicStreamingChoices(dynamic dynamicChoices) - { - try - { - var choices = new List(); - - // Handle null choices - if (dynamicChoices == null) - { - return choices; - } - - foreach (var choice in dynamicChoices) - { - try - { - var mappedChoice = MapSingleStreamingChoice(choice); - choices.Add(mappedChoice); - } - catch (Exception ex) - { - // Log but don't fail on individual choice processing - Logger.LogWarning(ex, "Error processing streaming choice"); - } - } - - return choices; - } - catch (Exception ex) - { - // Log and return empty choices rather than failing - Logger.LogError(ex, "Error mapping streaming choices"); - return new List(); - } - } - - /// - /// Maps a single dynamic streaming choice to a strongly-typed StreamingChoice object. - /// - /// The dynamic choice to map. - /// A strongly-typed StreamingChoice object. - private CoreModels.StreamingChoice MapSingleStreamingChoice(dynamic choice) - { - string? deltaContent = null; - string? deltaRole = null; - - // Debug logging to understand what's in the choice - if (choice.Delta != null) - { - try - { - // Convert dynamic to object to avoid dynamic dispatch issues - object deltaObj = choice.Delta; - var deltaJson = System.Text.Json.JsonSerializer.Serialize(deltaObj); - Logger.LogDebug("Delta content from provider: {Delta}", deltaJson); - - // Parse the JSON to handle various delta formats - using var doc = JsonDocument.Parse(deltaJson); - var root = doc.RootElement; - - // Try to get content - standard OpenAI format - if (root.TryGetProperty("content", out var contentElement)) - { - deltaContent = contentElement.GetString(); - } - // INCLUDE reasoning chunks from Groq's gpt-oss-120b model - // These contain the model's thinking process which we WANT to display - else if (root.TryGetProperty("reasoning", out var reasoningElement) && - root.TryGetProperty("channel", out var channelElement) && - channelElement.GetString() == "analysis") - { - // This is a reasoning chunk - treat it as content! - deltaContent = reasoningElement.GetString(); - Logger.LogDebug("Processing reasoning chunk as content: {Reasoning}", deltaContent); - } - - // Try to get role - if (root.TryGetProperty("role", out var roleElement)) - { - deltaRole = roleElement.GetString(); - } - } - catch (Exception ex) - { - Logger.LogDebug("Could not parse delta JSON: {Error}", ex.Message); - // Fall back to direct property access - try - { - deltaContent = choice.Delta?.Content; - deltaRole = choice.Delta?.Role; - } - catch - { - // Ignore if direct access fails - } - } - } - - var streamingChoice = new CoreModels.StreamingChoice - { - Index = choice.Index, - FinishReason = choice.FinishReason, - Delta = new CoreModels.DeltaContent - { - Role = deltaRole, - Content = deltaContent - } - }; - - // Handle tool calls if present - if (choice.Delta != null) - { - try - { - if (choice.Delta.ToolCalls != null) - { - streamingChoice.Delta.ToolCalls = MapToolCalls(choice.Delta.ToolCalls); - } - } - catch - { - // Tool calls might not be present or accessible - } - } - - return streamingChoice; - } - - /// - /// Maps dynamic tool calls to strongly-typed ToolCallChunk objects. - /// - /// The dynamic tool calls to map. - /// A list of strongly-typed ToolCallChunk objects. - private List MapToolCalls(dynamic toolCalls) - { - var mappedToolCalls = new List(); - - foreach (var toolCall in toolCalls) - { - try - { - var mappedToolCall = MapSingleToolCall(toolCall); - mappedToolCalls.Add(mappedToolCall); - } - catch (Exception ex) - { - // Log but don't fail - Logger.LogWarning(ex, "Error processing tool call in stream"); - } - } - - return mappedToolCalls; - } - - /// - /// Maps a single dynamic tool call to a strongly-typed ToolCallChunk object. - /// - /// The dynamic tool call to map. - /// A strongly-typed ToolCallChunk object. - private CoreModels.ToolCallChunk MapSingleToolCall(dynamic toolCall) - { - var mappedToolCall = new CoreModels.ToolCallChunk - { - Index = toolCall.Index, - Id = toolCall.Id, - Type = toolCall.Type - }; - - if (toolCall.Function != null) - { - mappedToolCall.Function = new CoreModels.FunctionCallChunk - { - Name = toolCall.Function.Name, - Arguments = toolCall.Function.Arguments - }; - } - - return mappedToolCall; - } - /// /// Configure the HTTP client with provider-specific settings. /// @@ -483,18 +25,15 @@ protected override void ConfigureHttpClient(HttpClient client, string apiKey) { client.BaseAddress = new Uri(BaseUrl); } - - // Add OpenAI API version header if needed - // client.DefaultRequestHeaders.Add("OpenAI-Version", "2023-05-15"); } /// public override Task GetCapabilitiesAsync(string? modelId = null) { var model = modelId ?? ProviderModelId; - - // For OpenAI-compatible providers, we provide sensible defaults - // Individual providers can override this with more specific capabilities + + // For OpenAI-compatible providers, we provide sensible defaults. + // Individual providers can override this with more specific capabilities. return Task.FromResult(new CoreModels.ProviderCapabilities { Provider = ProviderName, @@ -533,68 +72,6 @@ protected override void ConfigureHttpClient(HttpClient client, string apiKey) }); } - /// - /// Extracts a more helpful error message from exception details. - /// - /// The exception to extract information from. - /// An enhanced error message. - /// - /// This method attempts to extract more helpful error information from exceptions. - /// It looks for patterns in error messages and extracts the most relevant information. - /// - protected virtual string ExtractEnhancedErrorMessage(Exception ex) - { - // Try to extract error details in order of preference: - - // 1. Look for "Response:" pattern in the message - var msg = ex.Message; - var responseIdx = msg.IndexOf("Response:"); - if (responseIdx >= 0) - { - var extracted = msg.Substring(responseIdx + "Response:".Length).Trim(); - if (!string.IsNullOrEmpty(extracted)) - { - return extracted; - } - } - - // 2. Look for JSON content in the message - var jsonStart = msg.IndexOf("{"); - var jsonEnd = msg.LastIndexOf("}"); - if (jsonStart >= 0 && jsonEnd > jsonStart) - { - var jsonPart = msg.Substring(jsonStart, jsonEnd - jsonStart + 1); - try - { - var json = JsonDocument.Parse(jsonPart); - if (json.RootElement.TryGetProperty("error", out var errorElement)) - { - if (errorElement.TryGetProperty("message", out var messageElement)) - { - return messageElement.GetString() ?? msg; - } - } - } - catch - { - // If parsing fails, continue to the next method - } - } - - // 3. Look for Body data in the exception's Data dictionary - if (ex.Data.Contains("Body") && ex.Data["Body"] is string body && !string.IsNullOrEmpty(body)) - { - return body; - } - - // 4. Try inner exception - if (ex.InnerException != null && !string.IsNullOrEmpty(ex.InnerException.Message)) - { - return ex.InnerException.Message; - } - - // 5. Fallback to original message - return msg; - } + // ExtractEnhancedErrorMessage is inherited from BaseLLMClient } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/OpenRouter/OpenRouterClient.cs b/Shared/ConduitLLM.Providers/Providers/OpenRouter/OpenRouterClient.cs new file mode 100644 index 000000000..b7f4af936 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Providers/OpenRouter/OpenRouterClient.cs @@ -0,0 +1,292 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Core.Models; +using ConduitLLM.Providers.Configuration; +using InternalModels = ConduitLLM.Providers.Common.Models; +using CoreUtils = ConduitLLM.Core.Utilities; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Providers.OpenRouter +{ + /// + /// Client for interacting with OpenRouter's OpenAI-compatible API. + /// + /// + /// + /// OpenRouter is a meta-provider that routes requests to various underlying LLM providers + /// (OpenAI, Anthropic, Google, Meta, etc.) via a unified OpenAI-compatible API. + /// This client extends OpenAICompatibleClient with OpenRouter-specific headers and error handling. + /// + /// + /// Key features: + /// - Access to 100+ models from multiple providers through a single API + /// - Full OpenAI API compatibility for chat completions + /// - Support for streaming and non-streaming responses + /// - Tool/function calling support (dependent on routed model) + /// - Vision support (dependent on routed model) + /// - Model IDs use provider/model-name format (e.g., openai/gpt-4o) + /// - Provider routing preferences via ExtensionData (provider object) + /// + /// + /// OpenRouter-specific request parameters can be passed via ExtensionData: + /// - "provider": object with routing preferences (order, ignore, only, data_collection, sort, etc.) + /// - "transforms": string[] for prompt transformations (e.g., "middle-out") + /// - "models": string[] with "route": "fallback" for multi-model fallback + /// + /// + public class OpenRouterClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient + { + private static ProviderErrorMessages OpenRouterErrorMessages => + ProviderConfigurationRegistry.GetErrorMessages(ProviderType.OpenRouter); + + /// + /// Initializes a new instance of the class. + /// + /// The provider configuration. + /// The API key credential. + /// The model identifier to use (e.g., openai/gpt-4o, anthropic/claude-3.5-sonnet). + /// The logger to use. + /// Optional HTTP client factory for advanced usage scenarios. + /// Optional default model configuration for the provider. + public OpenRouterClient( + Provider provider, + ProviderKeyCredential keyCredential, + string providerModelId, + ILogger logger, + IHttpClientFactory? httpClientFactory = null, + ProviderDefaultModels? defaultModels = null) + : base( + provider, + keyCredential, + providerModelId, + logger, + httpClientFactory, + "OpenRouter", + baseUrl: ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.OpenRouter), + defaultModels: defaultModels) + { + } + + /// + /// Gets available models from OpenRouter's API. + /// + /// + /// + /// OpenRouter's /models endpoint is public and does not validate API keys. + /// To ensure the key is valid, this method first calls GET /key which requires + /// authentication and returns 401 for invalid keys. + /// + /// + /// OpenRouter's /models response does not include the 'owned_by' field that the base + /// OpenAI model data type requires. This override uses a permissive model type + /// that only requires the 'id' field. + /// + /// + public override async Task> GetModelsAsync( + string? apiKey = null, + CancellationToken cancellationToken = default) + { + try + { + return await ExecuteApiRequestAsync(async () => + { + using var client = CreateHttpClient(apiKey); + var headers = CreateStandardHeaders(apiKey); + + // Validate the API key first via GET /key (the /models endpoint is public + // and does not require authentication) + await ValidateApiKeyAsync(client, headers, cancellationToken); + + var endpoint = GetModelsEndpoint(); + + Logger.LogDebug("Getting available models from {Provider} at {Endpoint}", ProviderName, endpoint); + + var response = await CoreUtils.HttpClientHelper.GetJsonAsync( + client, + endpoint, + headers, + DefaultJsonOptions, + Logger, + cancellationToken); + + return response.Data + .Select(m => InternalModels.ExtendedModelInfo.Create(m.Id, ProviderName, m.Id)) + .ToList(); + }, "GetModels", cancellationToken); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to retrieve models from {Provider} API.", ProviderName); + throw; + } + } + + /// + /// Validates the API key by calling OpenRouter's GET /key endpoint. + /// + /// + /// Unlike the /models endpoint which is public, GET /key requires authentication + /// and returns 401 for invalid keys. + /// + private async Task ValidateApiKeyAsync( + HttpClient client, + Dictionary headers, + CancellationToken cancellationToken) + { + var keyEndpoint = $"{BaseUrl}/key"; + + Logger.LogDebug("Validating API key via {Endpoint}", keyEndpoint); + + using var request = new HttpRequestMessage(HttpMethod.Get, keyEndpoint); + foreach (var header in headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + using var response = await client.SendAsync(request, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + throw new Core.Exceptions.LLMCommunicationException( + "Invalid API key for OpenRouter. Please verify your API key is correct.", + System.Net.HttpStatusCode.Unauthorized, + null); + } + + if (!response.IsSuccessStatusCode) + { + Logger.LogWarning( + "OpenRouter key validation returned {StatusCode}, proceeding with model listing", + response.StatusCode); + } + } + + /// + /// Refines error classification for OpenRouter-specific error patterns. + /// + /// + /// OpenRouter has specific error semantics: + /// - 503: No provider meets routing requirements โ€” classify as ModelNotFound + /// since it typically means the requested model/routing combo is unavailable + /// - Error responses use numeric code field instead of OpenAI's string type + /// + protected override ProviderErrorType RefineErrorClassification( + ProviderErrorType baseType, + string? responseBody) + { + // Apply common patterns first (quota, rate limit, model not found) + var refined = base.RefineErrorClassification(baseType, responseBody); + if (refined != baseType) + return refined; + + if (string.IsNullOrEmpty(responseBody)) + return baseType; + + // OpenRouter-specific: 503 "no endpoints" โ†’ ModelNotFound + try + { + using var doc = JsonDocument.Parse(responseBody); + if (!doc.RootElement.TryGetProperty("error", out var error)) + return baseType; + + var message = error.TryGetProperty("message", out var msgProp) + ? msgProp.GetString() ?? "" + : ""; + + if (baseType == ProviderErrorType.ServiceUnavailable && + (message.Contains("no endpoints", StringComparison.OrdinalIgnoreCase) || + message.Contains("no provider", StringComparison.OrdinalIgnoreCase))) + { + return ProviderErrorType.ModelNotFound; + } + } + catch (JsonException) + { + // Not valid JSON, use base classification + } + + return baseType; + } + + /// + /// Extracts enhanced error messages for OpenRouter-specific error patterns. + /// Adds OpenRouter-specific keyword matching on top of base extraction. + /// + protected override string ExtractEnhancedErrorMessage(Exception ex) + { + var baseResult = base.ExtractEnhancedErrorMessage(ex); + + // If the base found something useful beyond the raw message, use it + if (!string.IsNullOrEmpty(baseResult) && + !baseResult.Equals(ex.Message) && + !baseResult.Contains("Exception of type")) + { + return baseResult; + } + + // OpenRouter-specific keyword matching + var msg = ex.Message; + + if (msg.Contains("model not found", StringComparison.OrdinalIgnoreCase) || + msg.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) + { + return OpenRouterErrorMessages.ModelNotFound; + } + + if (msg.Contains("rate limit", StringComparison.OrdinalIgnoreCase) || + msg.Contains("too many requests", StringComparison.OrdinalIgnoreCase)) + { + return OpenRouterErrorMessages.RateLimitExceeded; + } + + if (msg.Contains("credit", StringComparison.OrdinalIgnoreCase) || + msg.Contains("insufficient", StringComparison.OrdinalIgnoreCase)) + { + return "Insufficient OpenRouter credits. Add credits at openrouter.ai/credits."; + } + + if (msg.Contains("no endpoints found", StringComparison.OrdinalIgnoreCase) || + msg.Contains("no provider", StringComparison.OrdinalIgnoreCase)) + { + return "No OpenRouter provider available for this model. The model may be temporarily unavailable or routing constraints are too restrictive."; + } + + if (msg.Contains("moderation", StringComparison.OrdinalIgnoreCase) || + msg.Contains("flagged", StringComparison.OrdinalIgnoreCase)) + { + return "Request was flagged by OpenRouter content moderation."; + } + + // Fallback: use base result with provider prefix + return $"OpenRouter API error: {baseResult}"; + } + } + + /// + /// OpenRouter-specific models list response. + /// Unlike OpenAI, OpenRouter does not include 'owned_by' in model data. + /// + internal record OpenRouterModelsResponse + { + [JsonPropertyName("data")] + public required List Data { get; init; } + } + + /// + /// Minimal model data from OpenRouter's /models endpoint. + /// Only requires 'id' โ€” OpenRouter includes many extra fields (pricing, context_length, etc.) + /// that are safely ignored during deserialization. + /// + internal record OpenRouterModelData + { + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + } +} diff --git a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Authentication.cs b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Authentication.cs index 66d86bfad..a72f90ac7 100644 --- a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Authentication.cs +++ b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Authentication.cs @@ -1,13 +1,20 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Providers.Configuration; + using Microsoft.Extensions.Logging; namespace ConduitLLM.Providers.Replicate { + /// + /// ReplicateClient partial class containing authentication verification functionality. + /// public partial class ReplicateClient { /// /// Verifies Replicate authentication by making a test request to the account endpoint. /// - public override async Task VerifyAuthenticationAsync( + public override async Task VerifyAuthenticationAsync( string? apiKey = null, string? baseUrl = null, CancellationToken cancellationToken = default) @@ -16,20 +23,20 @@ public partial class ReplicateClient { var startTime = DateTime.UtcNow; var effectiveApiKey = !string.IsNullOrWhiteSpace(apiKey) ? apiKey : PrimaryKeyCredential.ApiKey; - + if (string.IsNullOrWhiteSpace(effectiveApiKey)) { - return Core.Interfaces.AuthenticationResult.Failure( + return AuthenticationResult.Failure( "API key is required", "No API token provided for Replicate authentication"); } // Create a test client using var client = CreateHttpClient(effectiveApiKey); - + // Make a request to the account endpoint - var accountUrl = $"{GetHealthCheckUrl(baseUrl)}/account"; - var response = await client.GetAsync(accountUrl, cancellationToken); + var accountUrl = GetHealthCheckUrl(baseUrl); + using var response = await client.GetAsync(accountUrl, cancellationToken); var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds; Logger.LogInformation("Replicate auth check returned status {StatusCode}", response.StatusCode); @@ -37,27 +44,27 @@ public partial class ReplicateClient // Check for authentication errors if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - return Core.Interfaces.AuthenticationResult.Failure( + return AuthenticationResult.Failure( "Authentication failed", - "Invalid API token - Replicate requires a valid API token"); + ProviderConfigurationRegistry.GetErrorMessages(ProviderType.Replicate).InvalidApiKey); } - + if (response.IsSuccessStatusCode) { - return Core.Interfaces.AuthenticationResult.Success( + return AuthenticationResult.Success( "Connected successfully to Replicate API", responseTime); } // Other errors - return Core.Interfaces.AuthenticationResult.Failure( + return AuthenticationResult.Failure( $"Unexpected response: {response.StatusCode}", await response.Content.ReadAsStringAsync(cancellationToken)); } catch (Exception ex) { Logger.LogError(ex, "Error verifying Replicate authentication"); - return Core.Interfaces.AuthenticationResult.Failure( + return AuthenticationResult.Failure( $"Authentication verification failed: {ex.Message}", ex.ToString()); } @@ -65,20 +72,26 @@ public partial class ReplicateClient /// /// Gets the health check URL for Replicate. + /// Replicate uses the /account endpoint for authentication verification. /// public override string GetHealthCheckUrl(string? baseUrl = null) { - var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) - ? baseUrl.TrimEnd('/') - : (Provider.BaseUrl ?? DefaultReplicateBaseUrl).TrimEnd('/'); - + var defaultBaseUrl = ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.Replicate) + ?? "https://api.replicate.com/v1"; + + var effectiveBaseUrl = !string.IsNullOrWhiteSpace(baseUrl) + ? baseUrl.TrimEnd('/') + : (Provider.BaseUrl ?? defaultBaseUrl).TrimEnd('/'); + // Ensure v1 is in the URL if (!effectiveBaseUrl.EndsWith("/v1")) { effectiveBaseUrl = $"{effectiveBaseUrl}/v1"; } - - return effectiveBaseUrl; + + // Use the health check endpoint from registry (/account for Replicate) + var healthCheckEndpoint = ProviderConfigurationRegistry.GetHealthCheckEndpoint(ProviderType.Replicate); + return $"{effectiveBaseUrl}{healthCheckEndpoint}"; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Chat.cs b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Chat.cs index 18f15d340..7e316d9e0 100644 --- a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Chat.cs +++ b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Chat.cs @@ -2,6 +2,7 @@ using System.Text.Json; using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Metrics; using ConduitLLM.Core.Models; using Microsoft.Extensions.Logging; @@ -18,30 +19,23 @@ public override async Task CreateChatCompletionAsync( { ValidateRequest(request, "CreateChatCompletionAsync"); - Logger.LogInformation("Creating chat completion with Replicate for model '{ModelId}'", ProviderModelId); + Logger.LogDebug("Creating chat completion with Replicate for model '{ModelId}'", ProviderModelId); - try + return await ExecuteApiRequestAsync(async () => { // Map the request to Replicate format and start prediction var predictionRequest = MapToPredictionRequest(request); var predictionResponse = await StartPredictionAsync(predictionRequest, apiKey, cancellationToken); // Poll until prediction completes or fails - var finalPrediction = await PollPredictionUntilCompletedAsync(predictionResponse.Id, apiKey, cancellationToken); - - // Process the final result - return MapToChatCompletionResponse(finalPrediction, request.Model); - } - catch (LLMCommunicationException) - { - // Re-throw LLMCommunicationException directly - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "An unexpected error occurred while processing Replicate chat completion"); - throw new LLMCommunicationException($"An unexpected error occurred: {ex.Message}", ex); - } + using var pollScope = BeginPollingScope("CreateChatCompletion"); + var finalPrediction = await PollPredictionUntilCompletedAsync( + predictionResponse.Id, apiKey, cancellationToken, pollScope); + + var response = MapToChatCompletionResponse(finalPrediction, request.Model); + RecordUsage(response.Usage, "CreateChatCompletion"); + return response; + }, "CreateChatCompletion", cancellationToken); } /// @@ -52,79 +46,100 @@ public override async IAsyncEnumerable StreamChatCompletion { ValidateRequest(request, "StreamChatCompletionAsync"); - Logger.LogInformation("Creating streaming chat completion with Replicate for model '{ModelId}'", ProviderModelId); + Logger.LogDebug("Creating streaming chat completion with Replicate for model '{ModelId}'", ProviderModelId); + + using var logScope = BeginProviderLogScope("StreamChatCompletion"); + var instrumentation = BeginStreamingScope("StreamChatCompletion"); + ProviderInstrumentation.PollingScope? pollScope = null; - // Variables to hold data outside the try block - ReplicatePredictionRequest? predictionRequest = null; - ReplicatePredictionResponse? predictionResponse = null; + ReplicatePredictionRequest? predictionRequest; + ReplicatePredictionResponse? predictionResponse; ReplicatePredictionResponse? finalPrediction = null; try { - // Replicate doesn't natively support streaming in the common SSE format - // Instead, we'll simulate streaming by getting the full response and breaking it into chunks - - // Start the prediction - predictionRequest = MapToPredictionRequest(request); - predictionResponse = await StartPredictionAsync(predictionRequest, apiKey, cancellationToken); - } - catch (LLMCommunicationException) - { - // Re-throw LLMCommunicationException directly - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "An unexpected error occurred starting Replicate prediction"); - throw new LLMCommunicationException($"An unexpected error occurred: {ex.Message}", ex); - } + try + { + // Replicate doesn't natively support streaming in the common SSE format + // Instead, we'll simulate streaming by getting the full response and breaking it into chunks + predictionRequest = MapToPredictionRequest(request); + predictionResponse = await StartPredictionAsync(predictionRequest, apiKey, cancellationToken); + } + catch (OperationCanceledException) + { + instrumentation.RecordFailure(nameof(OperationCanceledException)); + throw; + } + catch (LLMCommunicationException) + { + instrumentation.RecordFailure(nameof(LLMCommunicationException)); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "An unexpected error occurred starting Replicate prediction"); + instrumentation.RecordFailure(ex.GetType().Name); + throw new LLMCommunicationException($"An unexpected error occurred: {ex.Message}", ex); + } - // First chunk with role "assistant" - outside try block so we can yield - yield return CreateChatCompletionChunk( - string.Empty, - ProviderModelId, - true, - null, - request.Model); + // First chunk with role "assistant" + instrumentation.RecordChunk(); + yield return CreateChatCompletionChunk(string.Empty, ProviderModelId, isFirst: true); - try - { - // Poll until prediction completes or fails - if (predictionResponse != null) + try { - finalPrediction = await PollPredictionUntilCompletedAsync( - predictionResponse.Id, - apiKey, - cancellationToken, - true); // Set yield progress to true + if (predictionResponse != null) + { + pollScope = BeginPollingScope("StreamChatCompletion"); + finalPrediction = await PollPredictionUntilCompletedAsync( + predictionResponse.Id, apiKey, cancellationToken, pollScope); + } + } + catch (OperationCanceledException) + { + instrumentation.RecordFailure(nameof(OperationCanceledException)); + throw; + } + catch (LLMCommunicationException) + { + instrumentation.RecordFailure(nameof(LLMCommunicationException)); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "An unexpected error occurred polling Replicate prediction"); + instrumentation.RecordFailure(ex.GetType().Name); + throw new LLMCommunicationException($"An unexpected error occurred: {ex.Message}", ex); } - } - catch (LLMCommunicationException) - { - // Re-throw LLMCommunicationException directly - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "An unexpected error occurred polling Replicate prediction"); - throw new LLMCommunicationException($"An unexpected error occurred: {ex.Message}", ex); - } - // Extract content and yield the result - outside try block - if (finalPrediction != null) - { - var content = ExtractTextFromPredictionOutput(finalPrediction.Output); - if (!string.IsNullOrEmpty(content)) + if (finalPrediction != null) { - // Yield the content as a chunk - yield return CreateChatCompletionChunk( - content, - ProviderModelId, - false, - "stop", - request.Model); + var content = ExtractTextFromPredictionOutput(finalPrediction.Output); + if (!string.IsNullOrEmpty(content)) + { + // Replicate doesn't report token usage; estimate from output size. + var promptTokens = finalPrediction.Input != null + ? EstimateTokenCount(JsonSerializer.Serialize(finalPrediction.Input)) + : 0; + var completionTokens = EstimateTokenCount(content); + RecordUsage(new Usage + { + PromptTokens = promptTokens, + CompletionTokens = completionTokens, + TotalTokens = promptTokens + completionTokens + }, "StreamChatCompletion"); + + instrumentation.RecordChunk(); + yield return CreateChatCompletionChunk( + content, ProviderModelId, isFirst: false, finishReason: "stop"); + } } } + finally + { + pollScope?.Dispose(); + instrumentation.Dispose(); + } } private ReplicatePredictionRequest MapToPredictionRequest(ChatCompletionRequest request) @@ -203,7 +218,7 @@ private ReplicatePredictionRequest MapToPredictionRequest(ChatCompletionRequest input["top_p"] = request.TopP.Value; } - if (request.Stop != null && request.Stop.Count() > 0) + if (request.Stop != null && request.Stop.Any()) { input["stop_sequences"] = request.Stop; } diff --git a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Media.cs b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Media.cs index ed3b40443..8202d452d 100644 --- a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Media.cs +++ b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Media.cs @@ -17,28 +17,17 @@ public override async Task CreateImageAsync( Logger.LogInformation("Creating image with Replicate for model '{ModelId}'", ProviderModelId); - try + return await ExecuteApiRequestAsync(async () => { - // Map the request to Replicate format and start prediction var predictionRequest = MapToImageGenerationRequest(request); var predictionResponse = await StartPredictionAsync(predictionRequest, apiKey, cancellationToken); - // Poll until prediction completes or fails - var finalPrediction = await PollPredictionUntilCompletedAsync(predictionResponse.Id, apiKey, cancellationToken); + using var pollScope = BeginPollingScope("CreateImage"); + var finalPrediction = await PollPredictionUntilCompletedAsync( + predictionResponse.Id, apiKey, cancellationToken, pollScope); - // Process the final result return MapToImageGenerationResponse(finalPrediction, request.Model); - } - catch (LLMCommunicationException) - { - // Re-throw LLMCommunicationException directly - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "An unexpected error occurred while processing Replicate image generation"); - throw new LLMCommunicationException($"An unexpected error occurred: {ex.Message}", ex); - } + }, "CreateImage", cancellationToken); } /// @@ -55,43 +44,29 @@ public async Task CreateVideoAsync( { ValidateRequest(request, "CreateVideoAsync"); - Logger.LogInformation("Creating video with Replicate for model '{ModelId}' with prompt: '{Prompt}'", + Logger.LogInformation("Creating video with Replicate for model '{ModelId}' with prompt: '{Prompt}'", ProviderModelId, request.Prompt); - try + return await ExecuteApiRequestAsync(async () => { - // Map the request to Replicate format and start prediction var predictionRequest = MapToVideoGenerationRequest(request); - + Logger.LogDebug("Video generation request mapped. Input parameters: {@InputParams}", predictionRequest.Input); - + var predictionResponse = await StartPredictionAsync(predictionRequest, apiKey, cancellationToken); - - Logger.LogInformation("Video generation prediction started with ID: {PredictionId}, Status: {Status}", + + Logger.LogInformation("Video generation prediction started with ID: {PredictionId}, Status: {Status}", predictionResponse.Id, predictionResponse.Status); - // Poll until prediction completes or fails - var finalPrediction = await PollPredictionUntilCompletedAsync(predictionResponse.Id, apiKey, cancellationToken); + using var pollScope = BeginPollingScope("CreateVideo"); + var finalPrediction = await PollPredictionUntilCompletedAsync( + predictionResponse.Id, apiKey, cancellationToken, pollScope); - Logger.LogInformation("Video generation completed for prediction {PredictionId}. Final status: {Status}, Output: {@Output}", + Logger.LogInformation("Video generation completed for prediction {PredictionId}. Final status: {Status}, Output: {@Output}", finalPrediction.Id, finalPrediction.Status, finalPrediction.Output); - // Process the final result return MapToVideoGenerationResponse(finalPrediction, request.Model); - } - catch (LLMCommunicationException ex) - { - Logger.LogError(ex, "Video generation failed with LLMCommunicationException for model {ModelId}, prompt: '{Prompt}'", - ProviderModelId, request.Prompt); - // Re-throw LLMCommunicationException directly - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "An unexpected error occurred while processing Replicate video generation for model {ModelId}, prompt: '{Prompt}'", - ProviderModelId, request.Prompt); - throw new LLMCommunicationException($"An unexpected error occurred: {ex.Message}", ex); - } + }, "CreateVideo", cancellationToken); } private ReplicatePredictionRequest MapToImageGenerationRequest(ImageGenerationRequest request) diff --git a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Predictions.cs b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Predictions.cs index 176312bba..f3939afdb 100644 --- a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Predictions.cs +++ b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Predictions.cs @@ -1,7 +1,10 @@ +using System.Net; using System.Net.Http.Json; using System.Text.Json; using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Metrics; +using ConduitLLM.Providers.Helpers; using Microsoft.Extensions.Logging; @@ -17,7 +20,7 @@ private async Task CancelPredictionAsync(string predictionId, string? apiKey) try { using var client = CreateHttpClient(apiKey); - var response = await client.PostAsync($"predictions/{predictionId}/cancel", null); + using var response = await client.PostAsync($"predictions/{predictionId}/cancel", null); if (response.IsSuccessStatusCode) { @@ -64,15 +67,16 @@ private async Task StartPredictionAsync( } Logger.LogInformation("Sending request to Replicate: {BaseUrl}{Endpoint}", client.BaseAddress, endpoint); - var response = await client.PostAsJsonAsync(endpoint, request, cancellationToken); + using var response = await client.PostAsJsonAsync(endpoint, request, cancellationToken); if (!response.IsSuccessStatusCode) { string errorContent = await ReadErrorContentAsync(response, cancellationToken); - Logger.LogError("Replicate API prediction creation failed with status code {StatusCode}. Response: {ErrorContent}", + Logger.LogError("Replicate API prediction creation failed with status {StatusCode}. Response: {ErrorContent}", response.StatusCode, errorContent); throw new LLMCommunicationException( - $"Replicate API prediction creation failed with status code {response.StatusCode}. Response: {errorContent}"); + $"Replicate prediction creation failed: {errorContent}", + response.StatusCode, errorContent); } var predictionResponse = await response.Content.ReadFromJsonAsync( @@ -95,7 +99,11 @@ private async Task StartPredictionAsync( Logger.LogError(ex, "JSON error processing Replicate response"); throw new LLMCommunicationException("Error deserializing Replicate response", ex); } - catch (LLMCommunicationException) + catch (ConduitException) + { + throw; + } + catch (OperationCanceledException) { throw; } @@ -106,109 +114,150 @@ private async Task StartPredictionAsync( } } - private async Task PollPredictionUntilCompletedAsync( + private Task PollPredictionUntilCompletedAsync( string predictionId, string? apiKey, CancellationToken cancellationToken, - bool yieldProgress = false) + ProviderInstrumentation.PollingScope? instrumentation = null) + { + var options = new PollingOptions( + InitialDelay: DefaultPollingInterval, + MaxDelay: DefaultPollingInterval, + Timeout: MaxPollingDuration, + Backoff: BackoffStrategy.Fixed, + MaxConsecutiveTransientErrors: null); + + Logger.LogInformation("Starting to poll prediction {PredictionId}, max duration: {MaxDuration}", + predictionId, MaxPollingDuration); + + return AsyncJobPoller.PollAsync( + fetchStatus: ct => FetchPredictionStatusAsync(predictionId, apiKey, ct), + classify: ClassifyPredictionStatus, + extractSuccess: prediction => prediction, + extractFailure: prediction => ExtractPredictionFailure(prediction, predictionId), + options: options, + logger: Logger, + cancellationToken: cancellationToken, + onAbort: () => CancelPredictionAsync(predictionId, apiKey), + operationName: $"Replicate prediction {predictionId}", + instrumentation: instrumentation); + } + + private async Task FetchPredictionStatusAsync( + string predictionId, + string? apiKey, + CancellationToken cancellationToken) { - var startTime = DateTime.UtcNow; - var attemptCount = 0; - ReplicatePredictionResponse? prediction = null; + using var client = CreateHttpClient(apiKey); + using var response = await client.GetAsync($"predictions/{predictionId}", cancellationToken); - while (true) + if (!response.IsSuccessStatusCode) { - if (cancellationToken.IsCancellationRequested) - { - Logger.LogInformation("Prediction polling was canceled"); - throw new OperationCanceledException("Prediction polling was canceled", cancellationToken); - } + string errorContent = await ReadErrorContentAsync(response, cancellationToken); + Logger.LogError("Replicate API prediction polling failed with status {StatusCode}. Response: {ErrorContent}", + response.StatusCode, errorContent); + throw new LLMCommunicationException( + $"Replicate prediction polling failed: {errorContent}", + response.StatusCode, errorContent); + } - // Check if we've exceeded the maximum polling duration - if (DateTime.UtcNow - startTime > MaxPollingDuration) - { - Logger.LogError("Exceeded maximum polling duration for prediction {PredictionId}", predictionId); - throw new LLMCommunicationException($"Exceeded maximum polling duration for prediction {predictionId}"); - } + var prediction = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken); + if (prediction == null) + { + throw new LLMCommunicationException("Failed to deserialize Replicate prediction response"); + } + return prediction; + } - attemptCount++; - Logger.LogDebug("Polling prediction {PredictionId}, attempt {AttemptCount}", predictionId, attemptCount); + private static JobState ClassifyPredictionStatus(ReplicatePredictionResponse prediction) => + prediction.Status.ToLowerInvariant() switch + { + "succeeded" => JobState.Succeeded, + "failed" or "canceled" => JobState.Failed, + _ => JobState.InProgress, + }; - try - { - using var client = CreateHttpClient(apiKey); - var response = await client.GetAsync($"predictions/{predictionId}", cancellationToken); - - if (!response.IsSuccessStatusCode) - { - string errorContent = await ReadErrorContentAsync(response, cancellationToken); - Logger.LogError("Replicate API prediction polling failed with status code {StatusCode}. Response: {ErrorContent}", - response.StatusCode, errorContent); - throw new LLMCommunicationException( - $"Replicate API prediction polling failed with status code {response.StatusCode}. Response: {errorContent}"); - } - - prediction = await response.Content.ReadFromJsonAsync( - cancellationToken: cancellationToken); - - if (prediction == null) - { - throw new LLMCommunicationException("Failed to deserialize Replicate prediction response"); - } - - // Check prediction status - switch (prediction.Status.ToLowerInvariant()) - { - case "succeeded": - Logger.LogInformation("Prediction {PredictionId} completed successfully", predictionId); - return prediction; - - case "failed": - Logger.LogError("Prediction {PredictionId} failed: {Error}", predictionId, prediction.Error); - throw new LLMCommunicationException($"Replicate prediction failed: {prediction.Error}"); - - case "canceled": - Logger.LogWarning("Prediction {PredictionId} was canceled", predictionId); - throw new LLMCommunicationException("Replicate prediction was canceled"); - - case "starting": - case "processing": - // Still in progress, continue polling - Logger.LogDebug("Prediction {PredictionId} is {Status}", predictionId, prediction.Status); - break; - - default: - Logger.LogWarning("Prediction {PredictionId} has unknown status: {Status}", predictionId, prediction.Status); - break; - } - } - catch (HttpRequestException ex) - { - Logger.LogError(ex, "HTTP request error during prediction polling"); - throw new LLMCommunicationException($"HTTP request error during prediction polling: {ex.Message}", ex); - } - catch (JsonException ex) - { - Logger.LogError(ex, "JSON error processing prediction polling response"); - throw new LLMCommunicationException("Error deserializing prediction polling response", ex); - } - catch (LLMCommunicationException) - { - throw; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "An unexpected error occurred during prediction polling"); - throw new LLMCommunicationException($"An unexpected error occurred during prediction polling: {ex.Message}", ex); - } + private Exception ExtractPredictionFailure(ReplicatePredictionResponse prediction, string predictionId) + { + if (string.Equals(prediction.Status, "canceled", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogWarning("Prediction {PredictionId} was canceled by Replicate", predictionId); + return new LLMCommunicationException("Replicate prediction was canceled"); + } + Logger.LogError("Prediction {PredictionId} failed: {Error}", predictionId, prediction.Error); + return ClassifyReplicatePredictionError(prediction.Error, predictionId); + } + + /// + /// Classifies a Replicate prediction error into the appropriate exception type + /// based on the error message content. + /// + private Exception ClassifyReplicatePredictionError(string? error, string predictionId) + { + if (string.IsNullOrEmpty(error)) + { + return new LLMCommunicationException($"Replicate prediction {predictionId} failed with no error details"); + } + + var errorLower = error.ToLowerInvariant(); + + // Authentication / authorization errors + if (errorLower.Contains("invalid api token") || errorLower.Contains("unauthorized") || + errorLower.Contains("authentication") || errorLower.Contains("invalid token")) + { + return new LLMCommunicationException( + $"Replicate authentication error: {error}", + HttpStatusCode.Unauthorized, error); + } + + // Billing / quota errors + if (errorLower.Contains("insufficient") || errorLower.Contains("billing") || + errorLower.Contains("payment") || errorLower.Contains("quota") || + errorLower.Contains("credit")) + { + return new LLMCommunicationException( + $"Replicate billing error: {error}", + HttpStatusCode.PaymentRequired, error); + } + + // Rate limiting + if (errorLower.Contains("rate limit") || errorLower.Contains("too many requests") || + errorLower.Contains("throttl")) + { + return new RateLimitExceededException($"Replicate rate limit: {error}"); + } + + // Content policy + if (errorLower.Contains("nsfw") || errorLower.Contains("content policy") || + errorLower.Contains("safety") || errorLower.Contains("moderation") || + errorLower.Contains("not allowed")) + { + return new InvalidRequestException($"Content policy violation: {error}", "content_policy_violation", "prompt"); + } - // Add a delay before the next poll - await Task.Delay(DefaultPollingInterval, cancellationToken); + // Model errors + if (errorLower.Contains("model") && (errorLower.Contains("not found") || errorLower.Contains("does not exist"))) + { + return new ModelNotFoundException(ProviderModelId, $"Replicate model error: {error}"); + } + + // Input validation + if (errorLower.Contains("invalid input") || errorLower.Contains("validation") || + errorLower.Contains("invalid value") || errorLower.Contains("must be")) + { + return new InvalidRequestException($"Replicate input validation error: {error}"); } + + // Service errors + if (errorLower.Contains("service unavailable") || errorLower.Contains("internal error") || + errorLower.Contains("server error")) + { + return new ServiceUnavailableException($"Replicate service error: {error}"); + } + + // Default: unclassified provider error with the original message preserved + return new LLMCommunicationException($"Replicate prediction failed: {error}"); } } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Utilities.cs b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Utilities.cs index 722083ae0..e6c68b4c7 100644 --- a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Utilities.cs +++ b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.Utilities.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using ConduitLLM.Providers.Helpers; + using Microsoft.Extensions.Logging; namespace ConduitLLM.Providers.Replicate @@ -158,43 +160,7 @@ private int EstimateTokenCount(string text) return text.Length / 4; } - /// - /// Converts a JsonElement to its actual .NET value for proper serialization. - /// - /// The JsonElement to convert. - /// The converted value as a proper .NET type. - private static object? ConvertJsonElement(JsonElement element) - { - switch (element.ValueKind) - { - case JsonValueKind.String: - return element.GetString(); - case JsonValueKind.Number: - if (element.TryGetInt32(out var intValue)) - return intValue; - if (element.TryGetInt64(out var longValue)) - return longValue; - return element.GetDouble(); - case JsonValueKind.True: - return true; - case JsonValueKind.False: - return false; - case JsonValueKind.Null: - return null; - case JsonValueKind.Array: - return element.EnumerateArray() - .Select(e => ConvertJsonElement(e)) - .ToList(); - case JsonValueKind.Object: - var dict = new Dictionary(); - foreach (var property in element.EnumerateObject()) - { - dict[property.Name] = ConvertJsonElement(property.Value); - } - return dict; - default: - return element.ToString(); - } - } + private static object? ConvertJsonElement(JsonElement element) => + JsonElementConverter.ConvertJsonElement(element); } } \ No newline at end of file diff --git a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.cs b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.cs index 2f1adad68..8b645f697 100644 --- a/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/Replicate/ReplicateClient.cs @@ -3,6 +3,9 @@ using ConduitLLM.Configuration; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Models; +using ConduitLLM.Providers.Authentication; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; @@ -12,19 +15,28 @@ namespace ConduitLLM.Providers.Replicate /// Revised client for interacting with Replicate APIs using the new client hierarchy. /// Handles the asynchronous prediction workflow (start, poll, get result) for various model providers. /// - public partial class ReplicateClient : CustomProviderClient + public partial class ReplicateClient : BaseLLMClient { - // Default base URL for Replicate API - private const string DefaultReplicateBaseUrl = "https://api.replicate.com/v1/"; - // Default polling configuration private static readonly TimeSpan DefaultPollingInterval = TimeSpan.FromSeconds(2); private static readonly TimeSpan MaxPollingDuration = TimeSpan.FromMinutes(10); + /// + /// Base URL for the Replicate API (e.g. "https://api.replicate.com/v1"). + /// + protected readonly string BaseUrl; + + /// + /// Gets the Token authentication strategy for Replicate. + /// Replicate uses "Token" scheme instead of "Bearer". + /// + protected override IAuthenticationStrategy AuthenticationStrategy => TokenStrategy.Instance; + /// /// Initializes a new instance of the class. /// - /// The credentials for accessing the Replicate API. + /// The provider configuration. + /// The API key credential. /// The model identifier to use (typically a version hash or full slug). /// The logger to use. /// The HTTP client factory for creating HttpClient instances. @@ -43,9 +55,11 @@ public ReplicateClient( logger, httpClientFactory, "Replicate", - baseUrl: DefaultReplicateBaseUrl, - defaultModels: defaultModels) + defaultModels) { + BaseUrl = ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.Replicate) + ?? provider.BaseUrl + ?? throw new ConfigurationException($"Base URL must be provided for {ProviderName}"); } /// @@ -59,14 +73,57 @@ protected override void ValidateCredentials() } } + /// + protected override void ValidateRequest(TRequest request, string operationName) + { + base.ValidateRequest(request, operationName); + + switch (request) + { + case ChatCompletionRequest chat when chat.Messages is null || !chat.Messages.Any(): + throw new ValidationException($"{operationName}: Messages cannot be null or empty"); + case EmbeddingRequest embed when embed.Input is null: + throw new ValidationException($"{operationName}: Input cannot be null"); + case ImageGenerationRequest image when string.IsNullOrWhiteSpace(image.Prompt): + throw new ValidationException($"{operationName}: Prompt cannot be null or empty"); + } + } + + private static ChatCompletionChunk CreateChatCompletionChunk( + string content, + string model, + bool isFirst = false, + string? finishReason = null) => new() + { + Id = $"chatcmpl-{Guid.NewGuid():N}", + Object = "chat.completion.chunk", + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Model = model, + Choices = new List + { + new StreamingChoice + { + Index = 0, + Delta = new DeltaContent + { + Role = isFirst ? "assistant" : null, + Content = content, + }, + FinishReason = finishReason, + }, + }, + }; + /// protected override void ConfigureHttpClient(HttpClient client, string apiKey) { - // Customize configuration for Replicate - use Token auth + // Configure standard headers client.DefaultRequestHeaders.Accept.Clear(); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); client.DefaultRequestHeaders.Add("User-Agent", "ConduitLLM"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", apiKey); + + // Apply Token authentication via strategy + AuthenticationStrategy.ApplyAuthentication(client, apiKey); // Set the base address if not already set // Ensure base URL ends with trailing slash for relative path resolution @@ -78,11 +135,12 @@ protected override void ConfigureHttpClient(HttpClient client, string apiKey) } /// - /// Gets the default base URL for Replicate. + /// Gets the default base URL for Replicate from the configuration registry. /// protected override string GetDefaultBaseUrl() { - return DefaultReplicateBaseUrl; + return ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.Replicate) + ?? "https://api.replicate.com/v1"; } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.ErrorHandling.cs b/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.ErrorHandling.cs deleted file mode 100644 index 128c800d2..000000000 --- a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.ErrorHandling.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Text.Json; - -using ConduitLLM.Core.Exceptions; - -using Microsoft.Extensions.Logging; - -namespace ConduitLLM.Providers.SambaNova -{ - /// - /// SambaNovaClient partial class containing error handling methods. - /// - public partial class SambaNovaClient - { - /// - /// Processes HTTP errors and converts them to appropriate exceptions. - /// - /// The HTTP status code. - /// The response content. - /// Optional request ID for tracking. - /// An appropriate exception for the error. - private Exception ProcessHttpError(System.Net.HttpStatusCode statusCode, string responseContent, string? requestId = null) - { - Logger.LogError("SambaNova API error - Status: {StatusCode}, Content: {Content}, RequestId: {RequestId}", - statusCode, responseContent, requestId); - - return statusCode switch - { - System.Net.HttpStatusCode.Unauthorized => new ConfigurationException(Constants.ErrorMessages.InvalidApiKey), - System.Net.HttpStatusCode.TooManyRequests => new LLMCommunicationException(Constants.ErrorMessages.RateLimitExceeded), - System.Net.HttpStatusCode.NotFound => new ModelUnavailableException(Constants.ErrorMessages.ModelNotFound), - System.Net.HttpStatusCode.PaymentRequired => new LLMCommunicationException(Constants.ErrorMessages.QuotaExceeded), - System.Net.HttpStatusCode.BadRequest => ParseBadRequestError(responseContent), - System.Net.HttpStatusCode.InternalServerError => new LLMCommunicationException($"SambaNova API internal error: {responseContent}"), - System.Net.HttpStatusCode.ServiceUnavailable => new LLMCommunicationException("SambaNova API is temporarily unavailable. Please try again later."), - _ => new LLMCommunicationException($"SambaNova API error ({statusCode}): {responseContent}") - }; - } - - /// - /// Parses bad request errors to provide more specific error information. - /// - /// The response content containing error details. - /// An appropriate exception for the bad request error. - private Exception ParseBadRequestError(string responseContent) - { - try - { - using var document = JsonDocument.Parse(responseContent); - if (document.RootElement.TryGetProperty("error", out var errorElement)) - { - if (errorElement.TryGetProperty("message", out var messageElement)) - { - var errorMessage = messageElement.GetString(); - - // Check for specific error patterns - if (errorMessage?.Contains("model", StringComparison.OrdinalIgnoreCase) == true) - { - return new ModelUnavailableException($"Model error: {errorMessage}"); - } - - if (errorMessage?.Contains("token", StringComparison.OrdinalIgnoreCase) == true) - { - return new ValidationException($"Token limit error: {errorMessage}"); - } - - return new ValidationException($"Request error: {errorMessage}"); - } - } - } - catch (JsonException) - { - // Fall through to generic error if JSON parsing fails - } - - return new ValidationException($"Bad request: {responseContent}"); - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.Models.cs b/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.Models.cs index d0dfec548..82c1aa31d 100644 --- a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.Models.cs +++ b/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.Models.cs @@ -34,9 +34,9 @@ public override async Task> GetModelsAsync( // Load models from the static JSON file var models = await LoadStaticModelsAsync(cancellationToken); - if (models.Count() > 0) + if (models.Any()) { - Logger.LogInformation("Loaded {Count} SambaNova models from static configuration", models.Count); + Logger.LogInformation("Loaded {Count} SambaNova models from static configuration", models.Count()); return models; } diff --git a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.Validation.cs b/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.Validation.cs deleted file mode 100644 index 4b9162291..000000000 --- a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.Validation.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace ConduitLLM.Providers.SambaNova -{ - /// - /// SambaNovaClient partial class containing validation methods. - /// - public partial class SambaNovaClient - { - /// - /// Validates the model ID for SambaNova-specific requirements. - /// - /// The model ID to validate. - /// True if the model ID is valid, false otherwise. - private bool IsValidModelId(string modelId) - { - if (string.IsNullOrWhiteSpace(modelId)) - return false; - - // SambaNova model IDs follow specific patterns - var validPrefixes = new[] - { - "DeepSeek-", - "Meta-Llama-", - "Llama-", - "Qwen", - "E5-", - }; - - foreach (var prefix in validPrefixes) - { - if (modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } - } -} \ No newline at end of file diff --git a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.cs b/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.cs index 95bdfae27..514de70b3 100644 --- a/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.cs +++ b/Shared/ConduitLLM.Providers/Providers/SambaNova/SambaNovaClient.cs @@ -2,6 +2,7 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Exceptions; using ConduitLLM.Providers.Common.Models; +using ConduitLLM.Providers.Configuration; using Microsoft.Extensions.Logging; @@ -30,40 +31,11 @@ namespace ConduitLLM.Providers.SambaNova /// public partial class SambaNovaClient : ConduitLLM.Providers.OpenAICompatible.OpenAICompatibleClient { - // API configuration constants - private static class Constants - { - public static class Urls - { - /// - /// Default base URL for the SambaNova Cloud API - /// - public const string DefaultBaseUrl = "https://api.sambanova.ai/v1"; - } - - public static class Headers - { - /// - /// Authorization header for API key authentication - /// - public const string Authorization = "Authorization"; - } - - public static class Endpoints - { - public const string ChatCompletions = "/chat/completions"; - public const string Models = "/models"; - } - - public static class ErrorMessages - { - public const string MissingApiKey = "API key is missing for provider 'sambanova'"; - public const string RateLimitExceeded = "SambaNova API rate limit exceeded. Please try again later or reduce your request frequency."; - public const string InvalidApiKey = "Invalid SambaNova API key. Please check your credentials."; - public const string ModelNotFound = "The specified model is not available. Please check the model name and try again."; - public const string QuotaExceeded = "API quota exceeded. Please check your usage limits or upgrade your plan."; - } - } + /// + /// Gets the SambaNova-specific error messages from the configuration registry. + /// + private static ProviderErrorMessages SambaNovaErrorMessages => + ProviderConfigurationRegistry.GetErrorMessages(ProviderType.SambaNova); /// /// Fallback models for SambaNova when the models endpoint is not available @@ -74,18 +46,18 @@ public static class ErrorMessages ExtendedModelInfo.Create("DeepSeek-R1", "sambanova", "DeepSeek R1 (32k context)"), ExtendedModelInfo.Create("DeepSeek-V3-0324", "sambanova", "DeepSeek V3 0324 (32k context)"), ExtendedModelInfo.Create("DeepSeek-R1-Distill-Llama-70B", "sambanova", "DeepSeek R1 Distill Llama 70B (128k context)"), - + // Meta Llama models ExtendedModelInfo.Create("Meta-Llama-3.3-70B-Instruct", "sambanova", "Meta Llama 3.3 70B Instruct (128k context)"), ExtendedModelInfo.Create("Meta-Llama-3.1-8B-Instruct", "sambanova", "Meta Llama 3.1 8B Instruct (16k context)"), ExtendedModelInfo.Create("Llama-3.3-Swallow-70B-Instruct-v0.4", "sambanova", "Llama 3.3 Swallow 70B Instruct v0.4 (16k context)"), - + // Qwen models ExtendedModelInfo.Create("Qwen3-32B", "sambanova", "Qwen3 32B (8k context)"), - + // E5 models ExtendedModelInfo.Create("E5-Mistral-7B-Instruct", "sambanova", "E5 Mistral 7B Instruct (4k context)"), - + // Multimodal models ExtendedModelInfo.Create("Llama-4-Maverick-17B-128E-Instruct", "sambanova", "Llama 4 Maverick 17B 128E Instruct (128k context, multimodal)") }; @@ -93,12 +65,12 @@ public static class ErrorMessages /// /// Initializes a new instance of the SambaNovaClient class. /// - /// LLMProvider credentials containing API key and endpoint configuration. + /// The provider configuration. + /// The API key credential. /// The specific model ID to use with this provider. /// Logger for recording diagnostic information. /// Factory for creating HttpClient instances with proper configuration. /// Optional default model configuration for the provider. - /// Optional provider name override. If not specified, defaults to "sambanova". /// Thrown when any required parameter is null. /// Thrown when API key is missing. public SambaNovaClient( @@ -107,21 +79,20 @@ public SambaNovaClient( string providerModelId, ILogger logger, IHttpClientFactory httpClientFactory, - ProviderDefaultModels? defaultModels = null, - string? providerName = null) + ProviderDefaultModels? defaultModels = null) : base( provider, keyCredential, providerModelId, logger, httpClientFactory, - providerName ?? "sambanova", - baseUrl: Constants.Urls.DefaultBaseUrl, + "sambanova", + baseUrl: ProviderConfigurationRegistry.GetDefaultBaseUrl(ProviderType.SambaNova), defaultModels: defaultModels) { if (string.IsNullOrWhiteSpace(keyCredential.ApiKey)) { - throw new ConfigurationException(Constants.ErrorMessages.MissingApiKey); + throw new ConfigurationException(SambaNovaErrorMessages.MissingApiKey); } } @@ -138,4 +109,4 @@ protected override void ConfigureHttpClient(HttpClient client, string apiKey) client.DefaultRequestHeaders.UserAgent.ParseAdd("ConduitLLM-SambaNovaClient/1.0"); } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Providers/ResiliencePolicies.ErrorTracking.cs b/Shared/ConduitLLM.Providers/ResiliencePolicies.ErrorTracking.cs index 7fcd2899a..dea42f301 100644 --- a/Shared/ConduitLLM.Providers/ResiliencePolicies.ErrorTracking.cs +++ b/Shared/ConduitLLM.Providers/ResiliencePolicies.ErrorTracking.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -6,6 +7,7 @@ using ConduitLLM.Core.Models; using ConduitLLM.Core.Services; using ConduitLLM.Providers.Extensions; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Polly; @@ -21,12 +23,17 @@ public static partial class ResiliencePolicies /// /// Creates a retry policy with integrated provider error tracking. /// Tracks errors during retries and automatically disables keys on fatal errors. + /// Requires to be registered in + /// ; throws at construction if it is not. /// /// Service provider for resolving dependencies /// Maximum number of retry attempts (default: 3) /// Initial delay before first retry (default: 1 second) /// Maximum delay cap for any retry (default: 30 seconds) /// A configured Polly policy with error tracking + /// + /// Thrown when is not registered. + /// public static IAsyncPolicy GetRetryPolicyWithErrorTracking( IServiceProvider serviceProvider, int maxRetries = 3, @@ -34,17 +41,23 @@ public static IAsyncPolicy GetRetryPolicyWithErrorTracking( TimeSpan? maxDelay = null) { var logger = serviceProvider.GetService>(); - var errorTracker = serviceProvider.GetService(); - + // Caller (HttpClientExtensions) gates this method on the service being registered; + // require it here so a misconfigured DI container fails fast instead of silently + // dropping error tracking. + var errorTracker = serviceProvider.GetRequiredService(); + // Optional: not all hosts of this library register IHttpContextAccessor (e.g., + // background workers). Correlation falls back to Activity.Current in that case. + var httpContextAccessor = serviceProvider.GetService(); + // Use existing retry policy setup initialDelay ??= TimeSpan.FromSeconds(1); maxDelay ??= TimeSpan.FromSeconds(30); - + var delay = Polly.Contrib.WaitAndRetry.Backoff.DecorrelatedJitterBackoffV2( medianFirstRetryDelay: initialDelay.Value, retryCount: maxRetries, fastFirst: false); - + return HttpPolicyExtensions .HandleTransientHttpError() .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests) @@ -58,9 +71,8 @@ public static IAsyncPolicy GetRetryPolicyWithErrorTracking( retryAttempt, timespan.TotalMilliseconds, outcome.Result?.StatusCode); - - // Error tracking if service is available - if (errorTracker != null && outcome.Result != null) + + if (outcome.Result != null) { await TrackProviderErrorAsync( outcome.Result, @@ -68,6 +80,7 @@ await TrackProviderErrorAsync( retryAttempt, maxRetries, errorTracker, + httpContextAccessor, logger); } }) @@ -83,29 +96,30 @@ private static async Task TrackProviderErrorAsync( int retryAttempt, int maxRetries, IProviderErrorTrackingService errorTracker, + IHttpContextAccessor? httpContextAccessor, ILogger? logger) { try { // Get key context from ProviderKeyContext (set by ContextAwareLLMClient) var context = ProviderKeyContext.Current; - + if (context == null) { // No key context, can't track error return; } - + var keyId = context.KeyId; var providerId = context.ProviderId; - + // Classify the error var errorType = ClassifyResponseError(response); - + // Determine if we should track this error bool shouldTrack = false; string errorMessage = string.Empty; - + if (errorType == ProviderErrorType.RateLimitExceeded) { // Always track rate limit warnings @@ -118,7 +132,7 @@ private static async Task TrackProviderErrorAsync( shouldTrack = IsFatalError(errorType); errorMessage = await ExtractErrorMessageFromResponse(response); } - + if (shouldTrack) { await errorTracker.TrackErrorAsync(new ProviderErrorInfo @@ -129,9 +143,9 @@ await errorTracker.TrackErrorAsync(new ProviderErrorInfo ErrorMessage = errorMessage, HttpStatusCode = (int)response.StatusCode, RetryAttempt = retryAttempt, - RequestId = response.RequestMessage?.Headers.ToString() // Could extract correlation ID + RequestId = ResolveCorrelationId(httpContextAccessor) }); - + logger?.LogInformation( "Tracked {ErrorType} error for key {KeyId} on retry {RetryAttempt}/{MaxRetries}", errorType, keyId, retryAttempt, maxRetries); @@ -143,6 +157,50 @@ await errorTracker.TrackErrorAsync(new ProviderErrorInfo logger?.LogError(ex, "Failed to track provider error during retry"); } } + + /// + /// Resolves the correlation/request ID for the current call. + /// Order of precedence matches : + /// 1. HttpContext.Items["CorrelationId"] (set by CorrelationIdMiddleware) + /// 2. HttpContext.TraceIdentifier + /// 3. Activity.Current baggage item "correlation.id" + /// 4. Activity.Current.TraceId (W3C trace ID โ€” works in background jobs) + /// Returns null only when none of the above are populated. + /// + private static string? ResolveCorrelationId(IHttpContextAccessor? httpContextAccessor) + { + var httpContext = httpContextAccessor?.HttpContext; + if (httpContext != null) + { + if (httpContext.Items.TryGetValue("CorrelationId", out var corr) && + corr is string s && !string.IsNullOrEmpty(s)) + { + return s; + } + + if (!string.IsNullOrEmpty(httpContext.TraceIdentifier)) + { + return httpContext.TraceIdentifier; + } + } + + var activity = Activity.Current; + if (activity != null) + { + var baggage = activity.GetBaggageItem("correlation.id"); + if (!string.IsNullOrEmpty(baggage)) + { + return baggage; + } + + if (activity.TraceId != default) + { + return activity.TraceId.ToString(); + } + } + + return null; + } /// /// Classifies HTTP response into error type diff --git a/Shared/ConduitLLM.Providers/Streaming/GroqChunkConverter.cs b/Shared/ConduitLLM.Providers/Streaming/GroqChunkConverter.cs new file mode 100644 index 000000000..208caa940 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Streaming/GroqChunkConverter.cs @@ -0,0 +1,177 @@ +using System.Text.Json; + +using ConduitLLM.Core.Models; + +namespace ConduitLLM.Providers.Streaming +{ + /// + /// Chunk converter for Groq streaming responses. + /// + /// + /// Groq uses a non-standard location for usage data. Instead of the standard OpenAI + /// 'usage' field, Groq places usage information in 'x_groq.usage'. This converter + /// extracts and maps that data to the standard format. + /// + /// All other fields follow the standard OpenAI streaming format. + /// + public sealed class GroqChunkConverter : SseChunkConverterBase, IChunkConverter + { + /// + /// Singleton instance for reuse. + /// + public static readonly GroqChunkConverter Instance = new(); + + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + /// + public ChatCompletionChunk? Convert(JsonElement providerChunk, string modelId) + { + try + { + // Transform the chunk to extract x_groq.usage into standard usage field + var transformedJson = TransformGroqChunk(providerChunk); + var chunk = JsonSerializer.Deserialize(transformedJson, DefaultJsonOptions); + + if (chunk != null && !string.IsNullOrEmpty(modelId)) + { + chunk.Model = modelId; + chunk.OriginalModelAlias = modelId; + } + + return chunk; + } + catch (JsonException) + { + return null; + } + } + + /// + public bool IsErrorChunk(JsonElement chunk, out string? errorMessage) + { + errorMessage = null; + + try + { + if (chunk.TryGetProperty("error", out var errorElement)) + { + if (errorElement.TryGetProperty("message", out var messageElement)) + { + errorMessage = messageElement.GetString(); + return true; + } + + errorMessage = errorElement.GetRawText(); + return true; + } + } + catch + { + // If we can't parse the error, assume it's not an error chunk + } + + return false; + } + + /// + public bool IsFinalChunk(JsonElement chunk) + { + try + { + if (chunk.TryGetProperty("choices", out var choicesElement) && + choicesElement.ValueKind == JsonValueKind.Array) + { + foreach (var choice in choicesElement.EnumerateArray()) + { + if (choice.TryGetProperty("finish_reason", out var finishReasonElement) && + finishReasonElement.ValueKind != JsonValueKind.Null) + { + var finishReason = finishReasonElement.GetString(); + if (!string.IsNullOrEmpty(finishReason)) + { + return true; + } + } + } + } + } + catch + { + // If we can't determine, assume not final + } + + return false; + } + + /// + /// Transforms a Groq chunk to extract x_groq.usage into the standard usage field. + /// + /// The original Groq chunk. + /// JSON string with usage data in the standard location. + private static string TransformGroqChunk(JsonElement chunk) + { + // Check if x_groq.usage exists + if (!chunk.TryGetProperty("x_groq", out var xGroq) || + !xGroq.TryGetProperty("usage", out var xGroqUsage)) + { + // No transformation needed + return chunk.GetRawText(); + } + + // Create a new JSON object with usage extracted from x_groq + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + + // Copy all existing properties except x_groq + foreach (var property in chunk.EnumerateObject()) + { + if (property.Name != "x_groq") + { + property.WriteTo(writer); + } + } + + // Add usage field with data from x_groq.usage + writer.WritePropertyName("usage"); + writer.WriteStartObject(); + + if (xGroqUsage.TryGetProperty("prompt_tokens", out var promptTokens)) + { + writer.WriteNumber("prompt_tokens", promptTokens.GetInt32()); + } + + if (xGroqUsage.TryGetProperty("completion_tokens", out var completionTokens)) + { + writer.WriteNumber("completion_tokens", completionTokens.GetInt32()); + } + + if (xGroqUsage.TryGetProperty("total_tokens", out var totalTokens)) + { + writer.WriteNumber("total_tokens", totalTokens.GetInt32()); + } + + writer.WriteEndObject(); // End usage + writer.WriteEndObject(); // End root + } + + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + + /// + /// Checks if the chunk contains Groq-specific usage data. + /// + /// The chunk to check. + /// True if the chunk contains x_groq.usage data. + public static bool HasGroqUsage(JsonElement chunk) + { + return chunk.TryGetProperty("x_groq", out var xGroq) && + xGroq.TryGetProperty("usage", out _); + } + } +} diff --git a/Shared/ConduitLLM.Providers/Streaming/IChunkConverter.cs b/Shared/ConduitLLM.Providers/Streaming/IChunkConverter.cs new file mode 100644 index 000000000..edef39a89 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Streaming/IChunkConverter.cs @@ -0,0 +1,86 @@ +using ConduitLLM.Core.Models; + +namespace ConduitLLM.Providers.Streaming +{ + /// + /// Defines the contract for converting provider-specific streaming chunks + /// to the standardized ChatCompletionChunk format. + /// + /// The provider-specific chunk type. + /// + /// Different providers return streaming chunks in different formats. + /// This interface allows providers to implement their own conversion logic + /// while maintaining a consistent streaming interface. + /// + public interface IChunkConverter + { + /// + /// Converts a provider-specific chunk to the standardized format. + /// + /// The provider-specific chunk to convert. + /// The model ID for the response. + /// The converted chunk, or null if the chunk should be skipped. + ChatCompletionChunk? Convert(TProviderChunk providerChunk, string modelId); + + /// + /// Checks if the provider chunk represents an error. + /// + /// The provider chunk to check. + /// The error message if this is an error chunk. + /// True if this is an error chunk, false otherwise. + bool IsErrorChunk(TProviderChunk chunk, out string? errorMessage); + + /// + /// Checks if the provider chunk is the final chunk in the stream. + /// + /// The provider chunk to check. + /// True if this is the final chunk, false otherwise. + bool IsFinalChunk(TProviderChunk chunk); + } + + /// + /// Base class for SSE (Server-Sent Events) line parsing. + /// + public abstract class SseChunkConverterBase + { + /// + /// The SSE data prefix. + /// + protected const string DataPrefix = "data: "; + + /// + /// The SSE done marker for OpenAI-compatible APIs. + /// + protected const string DoneMarker = "[DONE]"; + + /// + /// Checks if a line is an SSE data line. + /// + /// The line to check. + /// True if the line starts with "data: ", false otherwise. + protected static bool IsDataLine(string line) + { + return line.StartsWith(DataPrefix, StringComparison.Ordinal); + } + + /// + /// Extracts the data content from an SSE data line. + /// + /// The SSE line. + /// The data content without the "data: " prefix. + protected static string ExtractData(string line) + { + return line.Substring(DataPrefix.Length); + } + + /// + /// Checks if the data content is the done marker. + /// + /// The data content to check. + /// True if this is the done marker, false otherwise. + protected static bool IsDoneMarker(string data) + { + return data.Equals(DoneMarker, StringComparison.Ordinal); + } + } +} diff --git a/Shared/ConduitLLM.Providers/Streaming/MiniMaxChunkConverter.cs b/Shared/ConduitLLM.Providers/Streaming/MiniMaxChunkConverter.cs new file mode 100644 index 000000000..f298f03ec --- /dev/null +++ b/Shared/ConduitLLM.Providers/Streaming/MiniMaxChunkConverter.cs @@ -0,0 +1,403 @@ +using System.Text.Json; + +using ConduitLLM.Core.Models; + +namespace ConduitLLM.Providers.Streaming +{ + /// + /// Chunk converter for MiniMax streaming responses. + /// + /// + /// MiniMax has several deviations from the OpenAI streaming protocol: + /// + /// 1. Error responses use base_resp.status_code != 0 instead of HTTP status codes + /// 2. Final chunk contains a complete 'message' field instead of 'delta' (protocol violation) + /// 3. Object type changes from "chat.completion.chunk" to "chat.completion" in final chunk + /// 4. Supports reasoning_content for models with reasoning tokens + /// + /// This converter handles these deviations to produce standard ChatCompletionChunk output. + /// + public sealed class MiniMaxChunkConverter : SseChunkConverterBase, IChunkConverter + { + /// + /// Singleton instance for reuse. + /// + public static readonly MiniMaxChunkConverter Instance = new(); + + /// + public ChatCompletionChunk? Convert(JsonElement providerChunk, string modelId) + { + try + { + var chunk = new ChatCompletionChunk + { + Id = GetStringProperty(providerChunk, "id") ?? Guid.NewGuid().ToString(), + Object = "chat.completion.chunk", // Always normalize to chunk type + Created = GetLongProperty(providerChunk, "created") ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Model = modelId, + Choices = new List() + }; + + if (providerChunk.TryGetProperty("choices", out var choicesElement) && + choicesElement.ValueKind == JsonValueKind.Array) + { + foreach (var choice in choicesElement.EnumerateArray()) + { + var streamingChoice = ConvertChoice(choice); + if (streamingChoice != null) + { + chunk.Choices.Add(streamingChoice); + } + } + } + + // Extract usage if present + if (providerChunk.TryGetProperty("usage", out var usageElement)) + { + chunk.Usage = ConvertUsage(usageElement); + } + + if (!string.IsNullOrEmpty(modelId)) + { + chunk.Model = modelId; + chunk.OriginalModelAlias = modelId; + } + + return chunk; + } + catch (JsonException) + { + return null; + } + } + + /// + public bool IsErrorChunk(JsonElement chunk, out string? errorMessage) + { + errorMessage = null; + + try + { + // MiniMax uses base_resp.status_code for errors + if (chunk.TryGetProperty("base_resp", out var baseResp)) + { + if (baseResp.TryGetProperty("status_code", out var statusCode) && + statusCode.TryGetInt32(out var code) && + code != 0) + { + if (baseResp.TryGetProperty("status_msg", out var statusMsg)) + { + errorMessage = statusMsg.GetString(); + } + else + { + errorMessage = $"MiniMax error code: {code}"; + } + return true; + } + } + + // Also check standard error field + if (chunk.TryGetProperty("error", out var errorElement)) + { + if (errorElement.TryGetProperty("message", out var messageElement)) + { + errorMessage = messageElement.GetString(); + return true; + } + + errorMessage = errorElement.GetRawText(); + return true; + } + } + catch + { + // If we can't parse the error, assume it's not an error chunk + } + + return false; + } + + /// + public bool IsFinalChunk(JsonElement chunk) + { + try + { + if (chunk.TryGetProperty("choices", out var choicesElement) && + choicesElement.ValueKind == JsonValueKind.Array) + { + foreach (var choice in choicesElement.EnumerateArray()) + { + if (choice.TryGetProperty("finish_reason", out var finishReasonElement) && + finishReasonElement.ValueKind != JsonValueKind.Null) + { + var finishReason = finishReasonElement.GetString(); + if (!string.IsNullOrEmpty(finishReason)) + { + return true; + } + } + } + } + } + catch + { + // If we can't determine, assume not final + } + + return false; + } + + /// + /// Converts a MiniMax choice to a StreamingChoice. + /// + /// + /// MiniMax sends a non-standard final chunk that includes a complete 'message' field + /// instead of using 'delta' consistently. This method handles both cases: + /// - Standard delta chunks (OpenAI-compliant) + /// - Non-standard message chunks (MiniMax protocol deviation) + /// + private static StreamingChoice? ConvertChoice(JsonElement choice) + { + var index = 0; + if (choice.TryGetProperty("index", out var indexElement)) + { + index = indexElement.GetInt32(); + } + + string? finishReason = null; + if (choice.TryGetProperty("finish_reason", out var finishReasonElement) && + finishReasonElement.ValueKind != JsonValueKind.Null) + { + finishReason = finishReasonElement.GetString(); + } + + string? content = null; + string? role = null; + List? toolCalls = null; + + // Check for non-standard 'message' field (MiniMax protocol deviation) + if (choice.TryGetProperty("message", out var messageElement) && + messageElement.ValueKind == JsonValueKind.Object) + { + // MiniMax's non-standard final chunk with complete message + // Skip content in final chunk to avoid duplicating what was already streamed + if (finishReason == "stop") + { + // Only extract role, skip content to avoid duplication + role = GetStringProperty(messageElement, "role"); + } + else + { + // For non-final chunks with message (unusual but handle it) + content = GetContentFromMessage(messageElement); + role = GetStringProperty(messageElement, "role"); + toolCalls = ExtractToolCalls(messageElement); + } + } + else if (choice.TryGetProperty("delta", out var deltaElement) && + deltaElement.ValueKind == JsonValueKind.Object) + { + // Standard OpenAI-compliant streaming chunk with delta + content = GetContentFromDelta(deltaElement); + role = GetStringProperty(deltaElement, "role"); + toolCalls = ExtractToolCalls(deltaElement); + } + + return new StreamingChoice + { + Index = index, + Delta = new DeltaContent + { + Role = role, + Content = content, + ToolCalls = toolCalls + }, + FinishReason = finishReason + }; + } + + /// + /// Extracts content from a delta element, checking both content and reasoning_content. + /// + private static string? GetContentFromDelta(JsonElement delta) + { + // Check standard content field first + if (delta.TryGetProperty("content", out var contentElement) && + contentElement.ValueKind == JsonValueKind.String) + { + var content = contentElement.GetString(); + if (!string.IsNullOrEmpty(content)) + { + return content; + } + } + + // Fall back to reasoning_content for models with reasoning tokens + if (delta.TryGetProperty("reasoning_content", out var reasoningElement) && + reasoningElement.ValueKind == JsonValueKind.String) + { + return reasoningElement.GetString(); + } + + return null; + } + + /// + /// Extracts content from a message element. + /// MiniMax's message.content can be string or object. + /// + private static string? GetContentFromMessage(JsonElement message) + { + // Check standard content field + if (message.TryGetProperty("content", out var contentElement)) + { + if (contentElement.ValueKind == JsonValueKind.String) + { + var content = contentElement.GetString(); + if (!string.IsNullOrEmpty(content)) + { + return content; + } + } + else if (contentElement.ValueKind != JsonValueKind.Null) + { + // Content might be an object, try to get raw text + var rawContent = contentElement.GetRawText(); + if (!string.IsNullOrEmpty(rawContent) && rawContent != "null") + { + return rawContent; + } + } + } + + // Fall back to reasoning_content + if (message.TryGetProperty("reasoning_content", out var reasoningElement) && + reasoningElement.ValueKind == JsonValueKind.String) + { + return reasoningElement.GetString(); + } + + return null; + } + + /// + /// Extracts tool calls from a delta or message element. + /// + private static List? ExtractToolCalls(JsonElement element) + { + // Check for function_call (MiniMax format) + if (element.TryGetProperty("function_call", out var functionCallElement) && + functionCallElement.ValueKind == JsonValueKind.Object) + { + var name = GetStringProperty(functionCallElement, "name"); + var arguments = GetStringProperty(functionCallElement, "arguments"); + + if (!string.IsNullOrEmpty(name) || !string.IsNullOrEmpty(arguments)) + { + return new List + { + new ToolCallChunk + { + Index = 0, + Id = Guid.NewGuid().ToString(), + Type = "function", + Function = new FunctionCallChunk + { + Name = name, + Arguments = arguments + } + } + }; + } + } + + // Check for tool_calls array (OpenAI format) + if (element.TryGetProperty("tool_calls", out var toolCallsElement) && + toolCallsElement.ValueKind == JsonValueKind.Array) + { + var toolCalls = new List(); + foreach (var toolCall in toolCallsElement.EnumerateArray()) + { + var toolCallChunk = new ToolCallChunk + { + Index = toolCall.TryGetProperty("index", out var idx) ? idx.GetInt32() : 0, + Id = GetStringProperty(toolCall, "id"), + Type = GetStringProperty(toolCall, "type") ?? "function" + }; + + if (toolCall.TryGetProperty("function", out var funcElement)) + { + toolCallChunk.Function = new FunctionCallChunk + { + Name = GetStringProperty(funcElement, "name"), + Arguments = GetStringProperty(funcElement, "arguments") + }; + } + + toolCalls.Add(toolCallChunk); + } + + return toolCalls.Count > 0 ? toolCalls : null; + } + + return null; + } + + /// + /// Converts MiniMax usage to standard Usage format. + /// + private static Usage? ConvertUsage(JsonElement usageElement) + { + if (usageElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + var usage = new Usage(); + + if (usageElement.TryGetProperty("prompt_tokens", out var promptTokens)) + { + usage.PromptTokens = promptTokens.GetInt32(); + } + + if (usageElement.TryGetProperty("completion_tokens", out var completionTokens)) + { + usage.CompletionTokens = completionTokens.GetInt32(); + } + + if (usageElement.TryGetProperty("total_tokens", out var totalTokens)) + { + usage.TotalTokens = totalTokens.GetInt32(); + } + + return usage; + } + + /// + /// Safely gets a string property from a JSON element. + /// + private static string? GetStringProperty(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop) && + prop.ValueKind == JsonValueKind.String) + { + return prop.GetString(); + } + return null; + } + + /// + /// Safely gets a long property from a JSON element. + /// + private static long? GetLongProperty(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop) && + prop.ValueKind == JsonValueKind.Number) + { + return prop.GetInt64(); + } + return null; + } + } +} diff --git a/Shared/ConduitLLM.Providers/Streaming/OpenAIChunkConverter.cs b/Shared/ConduitLLM.Providers/Streaming/OpenAIChunkConverter.cs new file mode 100644 index 000000000..f6d4bc7b5 --- /dev/null +++ b/Shared/ConduitLLM.Providers/Streaming/OpenAIChunkConverter.cs @@ -0,0 +1,135 @@ +using System.Text.Json; + +using ConduitLLM.Core.Models; + +namespace ConduitLLM.Providers.Streaming +{ + /// + /// Chunk converter for OpenAI-compatible streaming responses. + /// + /// + /// This converter handles the standard OpenAI streaming format used by most + /// OpenAI-compatible providers including OpenAI, Fireworks, DeepInfra, Cerebras, and SambaNova. + /// + /// The OpenAI streaming format closely matches the Core ChatCompletionChunk model, + /// so this converter primarily does direct deserialization with minimal transformation. + /// + public sealed class OpenAIChunkConverter : SseChunkConverterBase, IChunkConverter + { + /// + /// Singleton instance for reuse. + /// + public static readonly OpenAIChunkConverter Instance = new(); + + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + /// + public ChatCompletionChunk? Convert(JsonElement providerChunk, string modelId) + { + try + { + var chunkJson = providerChunk.GetRawText(); + var chunk = JsonSerializer.Deserialize(chunkJson, DefaultJsonOptions); + + if (chunk != null && !string.IsNullOrEmpty(modelId)) + { + // Preserve the original model alias + chunk.Model = modelId; + chunk.OriginalModelAlias = modelId; + } + + return chunk; + } + catch (JsonException) + { + // If deserialization fails, return null to skip this chunk + return null; + } + } + + /// + public bool IsErrorChunk(JsonElement chunk, out string? errorMessage) + { + errorMessage = null; + + try + { + if (chunk.TryGetProperty("error", out var errorElement)) + { + if (errorElement.TryGetProperty("message", out var messageElement)) + { + errorMessage = messageElement.GetString(); + return true; + } + + errorMessage = errorElement.GetRawText(); + return true; + } + } + catch + { + // If we can't parse the error, assume it's not an error chunk + } + + return false; + } + + /// + public bool IsFinalChunk(JsonElement chunk) + { + try + { + if (chunk.TryGetProperty("choices", out var choicesElement) && + choicesElement.ValueKind == JsonValueKind.Array) + { + foreach (var choice in choicesElement.EnumerateArray()) + { + if (choice.TryGetProperty("finish_reason", out var finishReasonElement) && + finishReasonElement.ValueKind != JsonValueKind.Null) + { + var finishReason = finishReasonElement.GetString(); + if (!string.IsNullOrEmpty(finishReason)) + { + return true; + } + } + } + } + } + catch + { + // If we can't determine, assume not final + } + + return false; + } + + /// + /// Parses a raw SSE line and converts it to a ChatCompletionChunk. + /// + /// The SSE data line (without the "data: " prefix). + /// The model ID to set on the chunk. + /// The converted chunk, or null if the line should be skipped. + public ChatCompletionChunk? ParseSseLine(string line, string modelId) + { + if (string.IsNullOrWhiteSpace(line) || IsDoneMarker(line)) + { + return null; + } + + try + { + var jsonElement = JsonDocument.Parse(line).RootElement; + return Convert(jsonElement, modelId); + } + catch (JsonException) + { + return null; + } + } + } +} diff --git a/Shared/ConduitLLM.Providers/Utilities/ParameterConverter.cs b/Shared/ConduitLLM.Providers/Utilities/ParameterConverter.cs index 39f4594b4..9fd4a85b1 100644 --- a/Shared/ConduitLLM.Providers/Utilities/ParameterConverter.cs +++ b/Shared/ConduitLLM.Providers/Utilities/ParameterConverter.cs @@ -40,7 +40,7 @@ public static class ParameterConverter /// An object suitable for OpenAI API (string or string array). public static object? ConvertStopSequences(List? stop) { - if (stop == null || stop.Count() == 0) return null; + if (stop == null || !stop.Any()) return null; // OpenAI accepts either a string or array of strings return stop.Count() == 1 ? stop[0] : stop; diff --git a/Shared/ConduitLLM.Security/Authorization/HealthKeyAuthorizationHandler.cs b/Shared/ConduitLLM.Security/Authorization/HealthKeyAuthorizationHandler.cs new file mode 100644 index 000000000..3a23227e9 --- /dev/null +++ b/Shared/ConduitLLM.Security/Authorization/HealthKeyAuthorizationHandler.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +using ConduitLLM.Core.Utilities; + +namespace ConduitLLM.Security.Authorization; + +/// +/// Authorization requirement for health endpoint access. +/// +public class HealthKeyRequirement : IAuthorizationRequirement { } + +/// +/// Authorization handler that allows health endpoint access from private networks +/// or when a valid health monitoring key is provided via the X-Conduit-Health-Key header. +/// +/// +/// This handler implements a tiered security model: +/// +/// Private network requests (10.x, 172.16-31.x, 192.168.x, 127.x) are always allowed +/// External requests require the CONDUIT_HEALTH_MONITORING_KEY via X-Conduit-Health-Key header +/// +/// +public class HealthKeyAuthorizationHandler : AuthorizationHandler +{ + private readonly string? _healthKey; + private readonly ILogger _logger; + + /// + /// Header name for the health monitoring key. + /// + public const string HealthKeyHeaderName = "X-Conduit-Health-Key"; + + /// + /// Environment variable name for the health monitoring key. + /// + public const string HealthKeyEnvVar = "CONDUIT_HEALTH_MONITORING_KEY"; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public HealthKeyAuthorizationHandler(ILogger logger) + { + _healthKey = Environment.GetEnvironmentVariable(HealthKeyEnvVar); + _logger = logger; + + if (string.IsNullOrEmpty(_healthKey)) + { + _logger.LogWarning( + "Health monitoring key ({EnvVar}) is not configured. " + + "External health endpoint access will be denied unless requests come from private networks.", + HealthKeyEnvVar); + } + } + + /// + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + HealthKeyRequirement requirement) + { + var httpContext = context.Resource as HttpContext; + if (httpContext == null) + { + _logger.LogDebug("Authorization context does not contain HttpContext, cannot evaluate health key requirement"); + return Task.CompletedTask; + } + + // Private network requests are always allowed + if (IpAddressHelper.IsPrivateNetworkRequest(httpContext)) + { + _logger.LogDebug( + "Health endpoint access granted for private network request from {RemoteIp}", + httpContext.Connection.RemoteIpAddress); + context.Succeed(requirement); + return Task.CompletedTask; + } + + // External requests: check for valid health key header + if (!string.IsNullOrEmpty(_healthKey) && + httpContext.Request.Headers.TryGetValue(HealthKeyHeaderName, out var providedKey) && + !string.IsNullOrEmpty(providedKey) && + string.Equals(providedKey, _healthKey, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Health endpoint access granted for external request from {RemoteIp} with valid key", + httpContext.Connection.RemoteIpAddress); + context.Succeed(requirement); + return Task.CompletedTask; + } + + // If we reach here, authorization fails (handler doesn't call Fail, just doesn't Succeed) + _logger.LogDebug( + "Health endpoint access denied for external request from {RemoteIp}: no valid key provided", + httpContext.Connection.RemoteIpAddress); + + return Task.CompletedTask; + } +} diff --git a/Shared/ConduitLLM.Security/ConduitLLM.Security.csproj b/Shared/ConduitLLM.Security/ConduitLLM.Security.csproj index d54b7cd7c..d77b3dd9b 100644 --- a/Shared/ConduitLLM.Security/ConduitLLM.Security.csproj +++ b/Shared/ConduitLLM.Security/ConduitLLM.Security.csproj @@ -9,12 +9,17 @@ - - - - - - + + + + + + + + + + + diff --git a/Shared/ConduitLLM.Security/Interfaces/ISecurityService.cs b/Shared/ConduitLLM.Security/Interfaces/ISecurityService.cs new file mode 100644 index 000000000..867dcc7ce --- /dev/null +++ b/Shared/ConduitLLM.Security/Interfaces/ISecurityService.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Http; +using ConduitLLM.Security.Models; + +namespace ConduitLLM.Security.Interfaces +{ + /// + /// Shared security service interface for both Admin and Gateway APIs. + /// Provides authentication checking, IP banning, and rate limiting. + /// + public interface ISecurityService + { + /// + /// Checks if a request is allowed based on all security rules + /// + Task IsRequestAllowedAsync(HttpContext context); + + /// + /// Records a failed authentication attempt for an IP address + /// + /// The client IP address + /// The key that was attempted (will be masked in logs) + Task RecordFailedAuthAsync(string ipAddress, string attemptedKey = ""); + + /// + /// Clears failed authentication attempts for an IP address + /// + Task ClearFailedAuthAttemptsAsync(string ipAddress); + + /// + /// Checks if an IP is banned due to failed authentication + /// + Task IsIpBannedAsync(string ipAddress); + } +} diff --git a/Shared/ConduitLLM.Security/Middleware/HealthEndpointAuthorizationMiddleware.cs b/Shared/ConduitLLM.Security/Middleware/HealthEndpointAuthorizationMiddleware.cs new file mode 100644 index 000000000..f7a20de88 --- /dev/null +++ b/Shared/ConduitLLM.Security/Middleware/HealthEndpointAuthorizationMiddleware.cs @@ -0,0 +1,145 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +using ConduitLLM.Core.Utilities; +using ConduitLLM.Security.Authorization; + +namespace ConduitLLM.Security.Middleware; + +/// +/// Middleware that protects health endpoints by requiring either: +/// +/// The request originates from a private network (10.x, 172.16-31.x, 192.168.x, 127.x) +/// A valid health monitoring key is provided via the X-Conduit-Health-Key header +/// +/// +/// +/// This middleware returns 404 Not Found for unauthorized external requests to hide +/// the existence of health endpoints from potential attackers (security through obscurity). +/// +public class HealthEndpointAuthorizationMiddleware +{ + private readonly RequestDelegate _next; + private readonly string? _healthKey; + private readonly ILogger _logger; + + /// + /// Path prefixes that are considered health endpoints. + /// + private static readonly string[] HealthPathPrefixes = new[] + { + "/health", + "/api/health" + }; + + /// + /// Initializes a new instance of the class. + /// + /// The next middleware in the pipeline. + /// The logger instance. + public HealthEndpointAuthorizationMiddleware( + RequestDelegate next, + ILogger logger) + { + _next = next; + _logger = logger; + _healthKey = Environment.GetEnvironmentVariable(HealthKeyAuthorizationHandler.HealthKeyEnvVar); + } + + /// + /// Processes the HTTP request and enforces health endpoint authorization. + /// + /// The HTTP context. + public async Task InvokeAsync(HttpContext context) + { + if (IsHealthEndpoint(context.Request.Path)) + { + if (!IsAuthorized(context)) + { + _logger.LogDebug( + "Health endpoint access denied for {Path} from {RemoteIp}: returning 404", + context.Request.Path, + context.Connection.RemoteIpAddress); + + // Return 404 to hide endpoint existence from unauthorized external requests + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + _logger.LogDebug( + "Health endpoint access granted for {Path} from {RemoteIp}", + context.Request.Path, + context.Connection.RemoteIpAddress); + } + + await _next(context); + } + + /// + /// Determines if the request path is a health endpoint. + /// + /// The request path. + /// True if the path is a health endpoint, false otherwise. + private static bool IsHealthEndpoint(PathString path) + { + if (!path.HasValue) + return false; + + var pathValue = path.Value; + foreach (var prefix in HealthPathPrefixes) + { + if (pathValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Determines if the request is authorized to access health endpoints. + /// + /// The HTTP context. + /// True if authorized, false otherwise. + private bool IsAuthorized(HttpContext context) + { + // Private network requests are always authorized + if (IpAddressHelper.IsPrivateNetworkRequest(context)) + { + return true; + } + + // External requests: check for valid health key header + if (!string.IsNullOrEmpty(_healthKey) && + context.Request.Headers.TryGetValue(HealthKeyAuthorizationHandler.HealthKeyHeaderName, out var providedKey) && + !string.IsNullOrEmpty(providedKey) && + string.Equals(providedKey, _healthKey, StringComparison.Ordinal)) + { + return true; + } + + return false; + } +} + +/// +/// Extension methods for adding health endpoint authorization middleware. +/// +public static class HealthEndpointAuthorizationMiddlewareExtensions +{ + /// + /// Adds the health endpoint authorization middleware to the application pipeline. + /// + /// The application builder. + /// The application builder for chaining. + /// + /// This middleware should be added early in the pipeline, before authentication, + /// to ensure health endpoints are protected even before other middleware runs. + /// + public static IApplicationBuilder UseHealthEndpointAuthorization(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} diff --git a/Services/ConduitLLM.Gateway/Middleware/SecurityHeadersMiddleware.cs b/Shared/ConduitLLM.Security/Middleware/SecurityHeadersMiddleware.cs similarity index 64% rename from Services/ConduitLLM.Gateway/Middleware/SecurityHeadersMiddleware.cs rename to Shared/ConduitLLM.Security/Middleware/SecurityHeadersMiddleware.cs index 1d6ac1a35..5f2a6f138 100644 --- a/Services/ConduitLLM.Gateway/Middleware/SecurityHeadersMiddleware.cs +++ b/Shared/ConduitLLM.Security/Middleware/SecurityHeadersMiddleware.cs @@ -1,15 +1,20 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using ConduitLLM.Gateway.Options; +using ConduitLLM.Security.Options; -namespace ConduitLLM.Gateway.Middleware +namespace ConduitLLM.Security.Middleware { /// - /// Middleware that adds security headers to HTTP responses for the Gateway API + /// Shared middleware that adds security headers to HTTP responses. + /// Works with any security options type that inherits from SecurityOptionsBase. /// - public class SecurityHeadersMiddleware + /// The security options type (must inherit from SecurityOptionsBase) + public class SecurityHeadersMiddleware where TOptions : SecurityOptionsBase { private readonly RequestDelegate _next; - private readonly ILogger _logger; + private readonly ILogger> _logger; private readonly SecurityHeadersOptions _options; /// @@ -17,8 +22,8 @@ public class SecurityHeadersMiddleware /// public SecurityHeadersMiddleware( RequestDelegate next, - ILogger logger, - IOptions securityOptions) + ILogger> logger, + IOptions securityOptions) { _next = next; _logger = logger; @@ -46,7 +51,7 @@ private void AddSecurityHeaders(HttpContext context) headers.Append("X-Content-Type-Options", "nosniff"); } - // X-XSS-Protection - Usually not needed for APIs but configurable + // X-XSS-Protection - Enable XSS filtering (for older browsers) if (_options.XXssProtection && !headers.ContainsKey("X-XSS-Protection")) { headers.Append("X-XSS-Protection", "1; mode=block"); @@ -76,7 +81,7 @@ private void AddSecurityHeaders(HttpContext context) { headers.Append("X-Content-Type", "application/json"); } - + // API version header if (!headers.ContainsKey("X-API-Version")) { @@ -93,11 +98,28 @@ private void AddSecurityHeaders(HttpContext context) public static class SecurityHeadersMiddlewareExtensions { /// - /// Adds security headers middleware to the application pipeline + /// Adds security headers middleware to the application pipeline for Admin API + /// + public static IApplicationBuilder UseAdminSecurityHeaders(this IApplicationBuilder builder) + { + return builder.UseMiddleware>(); + } + + /// + /// Adds security headers middleware to the application pipeline for Gateway API + /// + public static IApplicationBuilder UseGatewaySecurityHeaders(this IApplicationBuilder builder) + { + return builder.UseMiddleware>(); + } + + /// + /// Adds security headers middleware to the application pipeline with custom options type /// - public static IApplicationBuilder UseCoreApiSecurityHeaders(this IApplicationBuilder builder) + public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder builder) + where TOptions : SecurityOptionsBase { - return builder.UseMiddleware(); + return builder.UseMiddleware>(); } } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Security/Middleware/SecurityMiddlewareBase.cs b/Shared/ConduitLLM.Security/Middleware/SecurityMiddlewareBase.cs new file mode 100644 index 000000000..07ee642ae --- /dev/null +++ b/Shared/ConduitLLM.Security/Middleware/SecurityMiddlewareBase.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using ConduitLLM.Security.Models; +using ConduitLLM.Core.Utilities; + +namespace ConduitLLM.Security.Middleware +{ + /// + /// Base class for security middleware that provides common security check flow. + /// Derived classes can add API-specific security handling. + /// + public abstract class SecurityMiddlewareBase + { + /// + /// The next middleware in the pipeline + /// + protected readonly RequestDelegate Next; + + /// + /// Logger instance for security events + /// + protected readonly ILogger Logger; + + /// + /// Initializes a new instance of the security middleware base + /// + protected SecurityMiddlewareBase(RequestDelegate next, ILogger logger) + { + Next = next ?? throw new ArgumentNullException(nameof(next)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Processes the HTTP request through security checks. + /// Template method that calls the derived class's security check implementation. + /// + protected async Task ProcessRequestAsync(HttpContext context, Func> securityCheck) + { + var clientIp = GetClientIpAddress(context); + + // Check for early exit conditions (e.g., prior authentication failure) + if (ShouldSkipSecurityCheck(context)) + { + return; + } + + // Perform the security check + var result = await securityCheck(context); + + if (!result.IsAllowed) + { + await HandleSecurityViolationAsync(context, result, clientIp); + return; + } + + await Next(context); + } + + /// + /// Determines whether to skip security checks for this request. + /// Override in derived classes to add API-specific skip conditions. + /// + protected virtual bool ShouldSkipSecurityCheck(HttpContext context) + { + // Gateway-specific: if authentication already failed, don't continue + if (context.Response.StatusCode == 401) + { + return true; + } + return false; + } + + /// + /// Handles a security violation by logging, recording events, and sending the error response. + /// Override OnSecurityViolationAsync for additional handling (e.g., event monitoring). + /// + protected virtual async Task HandleSecurityViolationAsync(HttpContext context, SecurityCheckResult result, string clientIp) + { + Logger.LogWarning("Request blocked: {Reason} for path {Path} from IP {IP}", + result.Reason, + context.Request.Path, + clientIp); + + // Allow derived classes to record events or perform additional actions + await OnSecurityViolationAsync(context, result, clientIp); + + context.Response.StatusCode = result.StatusCode ?? 403; + + // Add response headers (e.g., rate limit headers) + foreach (var header in result.Headers) + { + context.Response.Headers.Append(header.Key, header.Value); + } + + // Return JSON error response + await context.Response.WriteAsJsonAsync(new + { + error = result.Reason, + code = result.StatusCode + }); + } + + /// + /// Called when a security violation occurs, before the error response is sent. + /// Override in derived classes to record security events. + /// + protected virtual Task OnSecurityViolationAsync(HttpContext context, SecurityCheckResult result, string clientIp) + { + // Default implementation does nothing - derived classes can override + return Task.CompletedTask; + } + + /// + /// Gets the client IP address from the request, considering proxy headers. + /// + protected virtual string GetClientIpAddress(HttpContext context) + { + return IpAddressHelper.GetClientIpAddress(context); + } + } +} diff --git a/Shared/ConduitLLM.Security/Models/SecurityCheckResult.cs b/Shared/ConduitLLM.Security/Models/SecurityCheckResult.cs new file mode 100644 index 000000000..ecde66c09 --- /dev/null +++ b/Shared/ConduitLLM.Security/Models/SecurityCheckResult.cs @@ -0,0 +1,65 @@ +namespace ConduitLLM.Security.Models +{ + /// + /// Result of a security check from the security middleware + /// + public class SecurityCheckResult + { + /// + /// Whether the request is allowed + /// + public bool IsAllowed { get; set; } + + /// + /// Reason for denial if not allowed + /// + public string Reason { get; set; } = ""; + + /// + /// HTTP status code to return + /// + public int? StatusCode { get; set; } + + /// + /// Additional headers to include in response (e.g., rate limit headers) + /// + public Dictionary Headers { get; set; } = new(); + + /// + /// Creates an allowed result + /// + public static SecurityCheckResult Allowed() => new() { IsAllowed = true }; + + /// + /// Creates a denied result + /// + public static SecurityCheckResult Denied(string reason, int statusCode = 403) + => new() { IsAllowed = false, Reason = reason, StatusCode = statusCode }; + + /// + /// Creates a rate limited result + /// + public static SecurityCheckResult RateLimited(string reason, int? limit = null, int? remaining = null, DateTime? resetsAt = null) + { + var result = new SecurityCheckResult + { + IsAllowed = false, + Reason = reason, + StatusCode = 429, + Headers = new Dictionary + { + ["Retry-After"] = "60" + } + }; + + if (limit.HasValue) + result.Headers["X-RateLimit-Limit"] = limit.Value.ToString(); + if (remaining.HasValue) + result.Headers["X-RateLimit-Remaining"] = remaining.Value.ToString(); + if (resetsAt.HasValue) + result.Headers["X-RateLimit-Reset"] = new DateTimeOffset(resetsAt.Value).ToUnixTimeSeconds().ToString(); + + return result; + } + } +} diff --git a/Shared/ConduitLLM.Security/Models/SecurityDataModels.cs b/Shared/ConduitLLM.Security/Models/SecurityDataModels.cs new file mode 100644 index 000000000..06b8e09a0 --- /dev/null +++ b/Shared/ConduitLLM.Security/Models/SecurityDataModels.cs @@ -0,0 +1,49 @@ +namespace ConduitLLM.Security.Models +{ + /// + /// Tracks failed authentication attempts for an IP address. + /// Shared across Admin and Gateway for consistent Redis/cache storage. + /// + public class FailedAuthData + { + public int Attempts { get; set; } + public string Source { get; set; } = ""; + public DateTime LastAttempt { get; set; } + public string LastAttemptedKey { get; set; } = ""; + } + + /// + /// Information about a banned IP address. + /// Shared across Admin and Gateway for consistent Redis/cache storage. + /// + public class BannedIpInfo + { + public DateTime BannedUntil { get; set; } + public int FailedAttempts { get; set; } + public string Source { get; set; } = ""; + public string Reason { get; set; } = ""; + public string LastAttemptedKey { get; set; } = ""; + } + + /// + /// Rate limit tracking data for an IP address. + /// Shared across Admin and Gateway for consistent Redis/cache storage. + /// + public class RateLimitData + { + public int Count { get; set; } + public string Source { get; set; } = ""; + public DateTime WindowStart { get; set; } + } + + /// + /// Result of a Virtual Key rate limit check (Gateway-specific). + /// + public class RateLimitCheckResult + { + public bool IsAllowed { get; set; } + public int? Remaining { get; set; } + public int? Limit { get; set; } + public DateTime? ResetsAt { get; set; } + } +} diff --git a/Shared/ConduitLLM.Security/Options/AdminSecurityOptions.cs b/Shared/ConduitLLM.Security/Options/AdminSecurityOptions.cs new file mode 100644 index 000000000..fc37484fd --- /dev/null +++ b/Shared/ConduitLLM.Security/Options/AdminSecurityOptions.cs @@ -0,0 +1,62 @@ +namespace ConduitLLM.Security.Options +{ + /// + /// Security configuration options specific to the Admin API + /// + public class AdminSecurityOptions : SecurityOptionsBase + { + /// + /// API authentication configuration + /// + public ApiAuthOptions ApiAuth { get; set; } = new(); + + /// + /// Initializes a new instance with Admin-specific defaults + /// + public AdminSecurityOptions() + { + // Admin API defaults - different from Gateway + IpFiltering.Enabled = false; // Admin typically accessed from known IPs + IpFiltering.ExcludedPaths = new List { "/health", "/swagger" }; + + RateLimiting.Enabled = false; // Admin operations less frequent + RateLimiting.MaxRequests = 100; + RateLimiting.ExcludedPaths = new List { "/health", "/swagger" }; + + Headers.XXssProtection = true; // Admin UI may render content + } + } + + /// + /// Admin API rate limiting options + /// + public class AdminRateLimitingOptions : RateLimitingOptionsBase + { + /// + /// Initializes with Admin-specific defaults + /// + public AdminRateLimitingOptions() + { + Enabled = false; + MaxRequests = 100; + WindowSeconds = 60; + ExcludedPaths = new List { "/health", "/swagger" }; + } + } + + /// + /// API authentication options for Admin API + /// + public class ApiAuthOptions + { + /// + /// Header name for API key + /// + public string ApiKeyHeader { get; set; } = "X-API-Key"; + + /// + /// Alternative header names for backward compatibility + /// + public List AlternativeHeaders { get; set; } = new() { "X-Master-Key" }; + } +} diff --git a/Shared/ConduitLLM.Security/Options/GatewaySecurityOptions.cs b/Shared/ConduitLLM.Security/Options/GatewaySecurityOptions.cs new file mode 100644 index 000000000..2ffeae26c --- /dev/null +++ b/Shared/ConduitLLM.Security/Options/GatewaySecurityOptions.cs @@ -0,0 +1,132 @@ +namespace ConduitLLM.Security.Options +{ + /// + /// Security configuration options specific to the Gateway API + /// + public class GatewaySecurityOptions : SecurityOptionsBase + { + /// + /// Virtual Key specific options + /// + public VirtualKeyOptions VirtualKey { get; set; } = new(); + + /// + /// Gateway-specific rate limiting with discovery options + /// + public new GatewayRateLimitingOptions RateLimiting { get; set; } = new(); + + /// + /// Initializes a new instance with Gateway-specific defaults + /// + public GatewaySecurityOptions() + { + // Gateway API defaults - different from Admin + IpFiltering.Enabled = true; // Gateway exposed to external traffic + IpFiltering.ExcludedPaths = new List { "/health", "/metrics" }; + + Headers.XXssProtection = false; // Not needed for API-only service + + FailedAuth.MaxAttempts = 10; // More lenient for virtual keys + } + } + + /// + /// Gateway-specific rate limiting options with discovery support + /// + public class GatewayRateLimitingOptions : RateLimitingOptionsBase + { + /// + /// Discovery-specific rate limiting configuration + /// + public DiscoveryRateLimitOptions Discovery { get; set; } = new(); + + /// + /// Initializes with Gateway-specific defaults + /// + public GatewayRateLimitingOptions() + { + Enabled = true; + MaxRequests = 1000; + WindowSeconds = 60; + ExcludedPaths = new List { "/health", "/metrics", "/swagger" }; + } + } + + /// + /// Discovery API specific rate limiting configuration + /// + public class DiscoveryRateLimitOptions + { + /// + /// Whether discovery-specific rate limiting is enabled + /// + public bool Enabled { get; set; } = true; + + /// + /// Maximum discovery requests per IP per window + /// + public int MaxRequests { get; set; } = 500; + + /// + /// Time window in seconds for discovery requests + /// + public int WindowSeconds { get; set; } = 300; // 5 minutes + + /// + /// Paths that count towards discovery rate limits + /// + public List DiscoveryPaths { get; set; } = new() + { + "/v1/discovery/", + "/v1/models/", + "/capabilities/" + }; + + /// + /// Maximum capability check requests per model per IP per window + /// + public int MaxCapabilityChecksPerModel { get; set; } = 20; + + /// + /// Time window for per-model capability checks in seconds + /// + public int CapabilityCheckWindowSeconds { get; set; } = 600; // 10 minutes + } + + /// + /// Virtual Key specific options + /// + public class VirtualKeyOptions + { + /// + /// Whether to enforce Virtual Key rate limits from database + /// + public bool EnforceRateLimits { get; set; } = true; + + /// + /// Whether to enforce Virtual Key budget limits + /// + public bool EnforceBudgetLimits { get; set; } = true; + + /// + /// Whether to enforce model access restrictions + /// + public bool EnforceModelRestrictions { get; set; } = true; + + /// + /// Cache duration for Virtual Key validation in seconds + /// + public int ValidationCacheSeconds { get; set; } = 60; + + /// + /// Headers to check for Virtual Key (in order of preference) + /// + public List KeyHeaders { get; set; } = new() + { + "Authorization", + "api-key", + "X-API-Key", + "X-Virtual-Key" + }; + } +} diff --git a/Services/ConduitLLM.Admin/Options/SecurityOptions.cs b/Shared/ConduitLLM.Security/Options/SecurityOptionsBase.cs similarity index 73% rename from Services/ConduitLLM.Admin/Options/SecurityOptions.cs rename to Shared/ConduitLLM.Security/Options/SecurityOptionsBase.cs index 84bd59460..c833b71ae 100644 --- a/Services/ConduitLLM.Admin/Options/SecurityOptions.cs +++ b/Shared/ConduitLLM.Security/Options/SecurityOptionsBase.cs @@ -1,9 +1,9 @@ -namespace ConduitLLM.Admin.Options +namespace ConduitLLM.Security.Options { /// - /// Security configuration options for the Admin API + /// Base security configuration options shared between Admin and Gateway APIs /// - public class SecurityOptions + public class SecurityOptionsBase { /// /// IP filtering configuration @@ -13,12 +13,12 @@ public class SecurityOptions /// /// Rate limiting configuration /// - public RateLimitingOptions RateLimiting { get; set; } = new(); + public RateLimitingOptionsBase RateLimiting { get; set; } = new(); /// /// Failed authentication protection configuration /// - public FailedAuthOptions FailedAuth { get; set; } = new(); + public FailedAuthOptionsBase FailedAuth { get; set; } = new(); /// /// Security headers configuration @@ -29,15 +29,10 @@ public class SecurityOptions /// Whether to use distributed (Redis) tracking for security features /// public bool UseDistributedTracking { get; set; } = true; - - /// - /// API authentication configuration - /// - public ApiAuthOptions ApiAuth { get; set; } = new(); } /// - /// IP filtering options + /// IP filtering options - identical for both APIs /// public class IpFilteringOptions { @@ -69,17 +64,13 @@ public class IpFilteringOptions /// /// Paths excluded from IP filtering /// - public List ExcludedPaths { get; set; } = new() - { - "/health", - "/swagger" - }; + public List ExcludedPaths { get; set; } = new() { "/health" }; } /// - /// Rate limiting options + /// Base rate limiting options - shared properties /// - public class RateLimitingOptions + public class RateLimitingOptionsBase { /// /// Whether rate limiting is enabled @@ -99,20 +90,16 @@ public class RateLimitingOptions /// /// Paths excluded from rate limiting /// - public List ExcludedPaths { get; set; } = new() - { - "/health", - "/swagger" - }; + public List ExcludedPaths { get; set; } = new() { "/health" }; } /// - /// Failed authentication protection options + /// Base failed authentication protection options /// - public class FailedAuthOptions + public class FailedAuthOptionsBase { /// - /// Whether IP banning is enabled + /// Whether failed auth protection is enabled /// public bool Enabled { get; set; } = true; @@ -125,10 +112,15 @@ public class FailedAuthOptions /// Duration in minutes for which an IP is banned /// public int BanDurationMinutes { get; set; } = 30; + + /// + /// Whether to track failed attempts across all keys (Gateway-specific, but safe to include in base) + /// + public bool TrackAcrossKeys { get; set; } = true; } /// - /// Security headers options + /// Security headers options - identical for both APIs /// public class SecurityHeadersOptions { @@ -168,23 +160,4 @@ public class HstsOptions /// public int MaxAge { get; set; } = 31536000; // 1 year } - - /// - /// API authentication options - /// - public class ApiAuthOptions - { - /// - /// Header name for API key - /// - public string ApiKeyHeader { get; set; } = "X-API-Key"; - - /// - /// Alternative header names for backward compatibility - /// - public List AlternativeHeaders { get; set; } = new() - { - "X-Master-Key" - }; - } -} \ No newline at end of file +} diff --git a/Shared/ConduitLLM.Security/Options/SecurityOptionsExtensions.cs b/Shared/ConduitLLM.Security/Options/SecurityOptionsExtensions.cs new file mode 100644 index 000000000..03fa83fe9 --- /dev/null +++ b/Shared/ConduitLLM.Security/Options/SecurityOptionsExtensions.cs @@ -0,0 +1,232 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ConduitLLM.Security.Options +{ + /// + /// Extension methods for configuring security options + /// + public static class SecurityOptionsExtensions + { + /// + /// Configures Admin security options from configuration + /// + public static IServiceCollection ConfigureAdminSecurityOptions( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure(options => + { + ConfigureBaseSecurityOptions(options, configuration, "CONDUIT_ADMIN_"); + + // API Authentication (Admin-specific) + options.ApiAuth.ApiKeyHeader = configuration["CONDUIT_ADMIN_API_KEY_HEADER"] ?? "X-API-Key"; + + var altHeaders = configuration["CONDUIT_ADMIN_API_KEY_ALT_HEADERS"]; + if (!string.IsNullOrWhiteSpace(altHeaders)) + { + options.ApiAuth.AlternativeHeaders = ParseCommaSeparatedList(altHeaders); + } + }); + + return services; + } + + /// + /// Configures Gateway security options from configuration + /// + public static IServiceCollection ConfigureGatewaySecurityOptions( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure(options => + { + ConfigureBaseSecurityOptions(options, configuration, "CONDUIT_CORE_"); + + // Gateway-specific rate limiting (override base) + options.RateLimiting.Enabled = GetConfigValue(configuration, "CONDUIT_CORE_RATE_LIMITING_ENABLED", + configuration.GetValue("CoreApi:Security:RateLimiting:Enabled", true)); + options.RateLimiting.MaxRequests = GetConfigValue(configuration, "CONDUIT_CORE_RATE_LIMIT_MAX_REQUESTS", + configuration.GetValue("CoreApi:Security:RateLimiting:MaxRequests", 1000)); + options.RateLimiting.WindowSeconds = GetConfigValue(configuration, "CONDUIT_CORE_RATE_LIMIT_WINDOW_SECONDS", + configuration.GetValue("CoreApi:Security:RateLimiting:WindowSeconds", 60)); + + var rateLimitExcluded = configuration["CONDUIT_CORE_RATE_LIMIT_EXCLUDED_PATHS"] + ?? configuration["CoreApi:Security:RateLimiting:ExcludedPaths"]; + if (!string.IsNullOrEmpty(rateLimitExcluded)) + { + options.RateLimiting.ExcludedPaths = ParseCommaSeparatedList(rateLimitExcluded); + } + + // Failed Auth (Gateway has TrackAcrossKeys) + options.FailedAuth.TrackAcrossKeys = GetConfigValue(configuration, "CONDUIT_CORE_TRACK_FAILED_AUTH_ACROSS_KEYS", + configuration.GetValue("CoreApi:Security:FailedAuth:TrackAcrossKeys", true)); + + // Virtual Key Options (Gateway-specific) + options.VirtualKey.EnforceRateLimits = GetConfigValue(configuration, "CONDUIT_CORE_ENFORCE_VKEY_RATE_LIMITS", + configuration.GetValue("CoreApi:Security:VirtualKey:EnforceRateLimits", true)); + options.VirtualKey.EnforceBudgetLimits = GetConfigValue(configuration, "CONDUIT_CORE_ENFORCE_VKEY_BUDGETS", + configuration.GetValue("CoreApi:Security:VirtualKey:EnforceBudgetLimits", true)); + options.VirtualKey.EnforceModelRestrictions = GetConfigValue(configuration, "CONDUIT_CORE_ENFORCE_VKEY_MODELS", + configuration.GetValue("CoreApi:Security:VirtualKey:EnforceModelRestrictions", true)); + options.VirtualKey.ValidationCacheSeconds = GetConfigValue(configuration, "CONDUIT_CORE_VKEY_CACHE_SECONDS", + configuration.GetValue("CoreApi:Security:VirtualKey:ValidationCacheSeconds", 60)); + }); + + return services; + } + + /// + /// Configures base security options shared between APIs + /// + private static void ConfigureBaseSecurityOptions( + SecurityOptionsBase options, + IConfiguration configuration, + string envPrefix) + { + // IP Filtering + var ipFilterEnabled = configuration[$"{envPrefix}IP_FILTERING_ENABLED"]; + if (!string.IsNullOrEmpty(ipFilterEnabled)) + { + options.IpFiltering.Enabled = bool.Parse(ipFilterEnabled); + } + + var ipFilterMode = configuration[$"{envPrefix}IP_FILTER_MODE"]; + if (!string.IsNullOrEmpty(ipFilterMode)) + { + options.IpFiltering.Mode = ipFilterMode; + } + + var allowPrivateIps = configuration[$"{envPrefix}IP_FILTER_ALLOW_PRIVATE"]; + if (!string.IsNullOrEmpty(allowPrivateIps)) + { + options.IpFiltering.AllowPrivateIps = bool.Parse(allowPrivateIps); + } + + var whitelist = configuration[$"{envPrefix}IP_FILTER_WHITELIST"]; + if (!string.IsNullOrWhiteSpace(whitelist)) + { + options.IpFiltering.Whitelist = ParseCommaSeparatedList(whitelist); + } + + var blacklist = configuration[$"{envPrefix}IP_FILTER_BLACKLIST"]; + if (!string.IsNullOrWhiteSpace(blacklist)) + { + options.IpFiltering.Blacklist = ParseCommaSeparatedList(blacklist); + } + + // Rate Limiting (base) + var rateLimitEnabled = configuration[$"{envPrefix}RATE_LIMITING_ENABLED"]; + if (!string.IsNullOrEmpty(rateLimitEnabled)) + { + options.RateLimiting.Enabled = bool.Parse(rateLimitEnabled); + } + + var maxRequests = configuration[$"{envPrefix}RATE_LIMIT_MAX_REQUESTS"]; + if (!string.IsNullOrEmpty(maxRequests)) + { + options.RateLimiting.MaxRequests = int.Parse(maxRequests); + } + + var windowSeconds = configuration[$"{envPrefix}RATE_LIMIT_WINDOW_SECONDS"]; + if (!string.IsNullOrEmpty(windowSeconds)) + { + options.RateLimiting.WindowSeconds = int.Parse(windowSeconds); + } + + var rateLimitExcluded = configuration[$"{envPrefix}RATE_LIMIT_EXCLUDED_PATHS"]; + if (!string.IsNullOrWhiteSpace(rateLimitExcluded)) + { + options.RateLimiting.ExcludedPaths = ParseCommaSeparatedList(rateLimitExcluded); + } + + // Failed Authentication Protection + var failedAuthEnabled = configuration[$"{envPrefix}IP_BANNING_ENABLED"]; + if (!string.IsNullOrEmpty(failedAuthEnabled)) + { + options.FailedAuth.Enabled = bool.Parse(failedAuthEnabled); + } + + var maxAttempts = configuration[$"{envPrefix}MAX_FAILED_AUTH_ATTEMPTS"]; + if (!string.IsNullOrEmpty(maxAttempts)) + { + options.FailedAuth.MaxAttempts = int.Parse(maxAttempts); + } + + var banDuration = configuration[$"{envPrefix}AUTH_BAN_DURATION_MINUTES"]; + if (!string.IsNullOrEmpty(banDuration)) + { + options.FailedAuth.BanDurationMinutes = int.Parse(banDuration); + } + + // Distributed Tracking (shared key) + var useDistributed = configuration["CONDUIT_SECURITY_USE_DISTRIBUTED_TRACKING"]; + if (!string.IsNullOrEmpty(useDistributed)) + { + options.UseDistributedTracking = bool.Parse(useDistributed); + } + + // Security Headers + ConfigureSecurityHeaders(options.Headers, configuration, envPrefix); + } + + /// + /// Configures security headers options + /// + private static void ConfigureSecurityHeaders( + SecurityHeadersOptions headers, + IConfiguration configuration, + string envPrefix) + { + var xContentTypeOptions = configuration[$"{envPrefix}SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED"] + ?? configuration[$"{envPrefix}SECURITY_HEADERS_CONTENT_TYPE"]; + if (!string.IsNullOrEmpty(xContentTypeOptions)) + { + headers.XContentTypeOptions = bool.Parse(xContentTypeOptions); + } + + var xXssProtection = configuration[$"{envPrefix}SECURITY_HEADERS_X_XSS_PROTECTION_ENABLED"] + ?? configuration[$"{envPrefix}SECURITY_HEADERS_XSS"]; + if (!string.IsNullOrEmpty(xXssProtection)) + { + headers.XXssProtection = bool.Parse(xXssProtection); + } + + var hstsEnabled = configuration[$"{envPrefix}SECURITY_HEADERS_HSTS_ENABLED"]; + if (!string.IsNullOrEmpty(hstsEnabled)) + { + headers.Hsts.Enabled = bool.Parse(hstsEnabled); + } + + var hstsMaxAge = configuration[$"{envPrefix}SECURITY_HEADERS_HSTS_MAX_AGE"]; + if (!string.IsNullOrEmpty(hstsMaxAge)) + { + headers.Hsts.MaxAge = int.Parse(hstsMaxAge); + } + } + + /// + /// Helper to get config value with fallback + /// + private static T GetConfigValue(IConfiguration configuration, string envKey, T fallback) where T : struct + { + var value = configuration[envKey]; + if (string.IsNullOrEmpty(value)) + { + return fallback; + } + + return (T)Convert.ChangeType(value, typeof(T)); + } + + /// + /// Parses a comma-separated string into a list + /// + private static List ParseCommaSeparatedList(string value) + { + return value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .ToList(); + } + } +} diff --git a/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Analysis.cs b/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Analysis.cs index 4ed9dd573..a7d5fc9c6 100644 --- a/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Analysis.cs +++ b/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Analysis.cs @@ -151,7 +151,7 @@ private async Task DetectAnomalousPatterns() } // Detect anomalies - if (state.EndpointAccess.Count() == 0 && state.EndpointAccess.Count() > _options.AnomalousEndpointThreshold && + if (state.EndpointAccess.Count() > _options.AnomalousEndpointThreshold && state.TotalRequests > 50) { RecordAnomalousAccess(profile.Key, "", "Endpoint Scanning", diff --git a/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Metrics.cs b/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Metrics.cs index 19065c29f..8fb5aed3c 100644 --- a/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Metrics.cs +++ b/Shared/ConduitLLM.Security/Services/SecurityEventMonitoringService.Metrics.cs @@ -104,7 +104,7 @@ public Task> GetRecentSecurityEventsAsync(int minutes = 6 /// private ThreatLevel CalculateThreatLevel(List recentEvents) { - if (recentEvents.Count() == 0) + if (!recentEvents.Any()) return ThreatLevel.None; var failureRate = (double)recentEvents.Count(e => e.EventType == SecurityEventType.AuthenticationFailure) / recentEvents.Count(); diff --git a/Shared/ConduitLLM.Security/Services/SecurityServiceBase.cs b/Shared/ConduitLLM.Security/Services/SecurityServiceBase.cs new file mode 100644 index 000000000..b3c6ad8ac --- /dev/null +++ b/Shared/ConduitLLM.Security/Services/SecurityServiceBase.cs @@ -0,0 +1,338 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ConduitLLM.Core.Utilities; +using ConduitLLM.Security.Models; +using ConduitLLM.Security.Options; + +namespace ConduitLLM.Security.Services +{ + /// + /// Base class for security services shared between Admin and Gateway APIs. + /// Provides common IP banning, rate limiting, IP filtering, and failed auth tracking. + /// + public abstract class SecurityServiceBase : Interfaces.ISecurityService + { + protected readonly ILogger Logger; + protected readonly IMemoryCache MemoryCache; + protected readonly IDistributedCache? DistributedCache; + + // Cache key prefixes โ€” shared across Admin and Gateway for consistent tracking + protected const string RateLimitPrefix = "rate_limit:"; + protected const string FailedLoginPrefix = "failed_login:"; + protected const string BanPrefix = "ban:"; + + /// + /// Service identifier for cache tracking (e.g., "admin-api", "core-api") + /// + protected abstract string ServiceName { get; } + + /// + /// The security options for this service + /// + protected abstract SecurityOptionsBase Options { get; } + + protected SecurityServiceBase( + ILogger logger, + IMemoryCache memoryCache, + IDistributedCache? distributedCache) + { + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + MemoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); + DistributedCache = distributedCache; + } + + /// + public abstract Task IsRequestAllowedAsync(HttpContext context); + + /// + public virtual async Task RecordFailedAuthAsync(string ipAddress, string attemptedKey = "") + { + if (!Options.FailedAuth.Enabled) + { + Logger.LogDebug("Failed auth recording is disabled via configuration for IP {IpAddress}", ipAddress); + return; + } + + var key = $"{FailedLoginPrefix}{ipAddress}"; + var banKey = $"{BanPrefix}{ipAddress}"; + + var attempts = await GetCacheValueAsync(key); + attempts++; + + var maskedKey = MaskKey(attemptedKey); + Logger.LogWarning( + "Failed authentication attempt {Attempts}/{MaxAttempts} for IP {IpAddress}{KeyInfo}", + attempts, Options.FailedAuth.MaxAttempts, ipAddress, + string.IsNullOrEmpty(maskedKey) ? "" : $" with key {maskedKey}"); + + if (attempts >= Options.FailedAuth.MaxAttempts) + { + var banInfo = new BannedIpInfo + { + BannedUntil = DateTime.UtcNow.AddMinutes(Options.FailedAuth.BanDurationMinutes), + FailedAttempts = attempts, + Source = ServiceName, + Reason = "Exceeded max failed authentication attempts", + LastAttemptedKey = maskedKey + }; + + await SetCacheValueAsync(banKey, banInfo, TimeSpan.FromMinutes(Options.FailedAuth.BanDurationMinutes)); + Logger.LogWarning("IP {IpAddress} has been banned after {Attempts} failed authentication attempts", + ipAddress, attempts); + + // Record the ban event (Gateway overrides to add security event monitoring) + OnIpBanned(ipAddress, banInfo, attempts); + + await RemoveCacheValueAsync(key); + } + else + { + var authData = new FailedAuthData + { + Attempts = attempts, + Source = ServiceName, + LastAttempt = DateTime.UtcNow, + LastAttemptedKey = maskedKey + }; + + await SetCacheValueAsync(key, authData, TimeSpan.FromMinutes(Options.FailedAuth.BanDurationMinutes), sliding: true); + } + } + + /// + /// Called when an IP is banned. Override in derived classes to add monitoring events. + /// + protected virtual void OnIpBanned(string ipAddress, BannedIpInfo banInfo, int attempts) + { + // Default: no additional action. Gateway overrides to report to ISecurityEventMonitoringService. + } + + /// + public virtual async Task ClearFailedAuthAttemptsAsync(string ipAddress) + { + var key = $"{FailedLoginPrefix}{ipAddress}"; + await RemoveCacheValueAsync(key); + Logger.LogDebug("Cleared failed authentication attempts for IP {IpAddress}", ipAddress); + } + + /// + public virtual async Task IsIpBannedAsync(string ipAddress) + { + if (!Options.FailedAuth.Enabled) + { + return false; + } + + var banKey = $"{BanPrefix}{ipAddress}"; + + if (Options.UseDistributedTracking && DistributedCache != null) + { + var cachedValue = await DistributedCache.GetStringAsync(banKey); + if (!string.IsNullOrEmpty(cachedValue)) + { + var banInfo = JsonSerializer.Deserialize(cachedValue); + return banInfo?.BannedUntil > DateTime.UtcNow; + } + } + else + { + var banInfo = MemoryCache.Get(banKey); + return banInfo?.BannedUntil > DateTime.UtcNow; + } + + return false; + } + + /// + /// Checks IP-based rate limiting + /// + protected async Task CheckIpRateLimitAsync(string ipAddress) + { + var key = $"{RateLimitPrefix}{ServiceName}:{ipAddress}"; + var requestCount = await GetCacheValueAsync(key); + requestCount++; + + if (requestCount > Options.RateLimiting.MaxRequests) + { + Logger.LogWarning("Rate limit exceeded for IP {IpAddress}: {Count} requests in {Window} seconds", + ipAddress, requestCount, Options.RateLimiting.WindowSeconds); + + return SecurityCheckResult.RateLimited( + "Rate limit exceeded", + Options.RateLimiting.MaxRequests); + } + + var rateLimitData = new RateLimitData + { + Count = requestCount, + Source = ServiceName, + WindowStart = DateTime.UtcNow + }; + + await SetCacheValueAsync(key, rateLimitData, TimeSpan.FromSeconds(Options.RateLimiting.WindowSeconds)); + + return SecurityCheckResult.Allowed(); + } + + /// + /// Checks IP filtering rules (whitelist/blacklist + database). + /// Subclasses must provide the database check via . + /// + protected async Task CheckIpFilterAsync(string ipAddress) + { + // Check if it's a private IP and we allow private IPs + if (Options.IpFiltering.AllowPrivateIps && IpAddressHelper.IsPrivateIp(ipAddress)) + { + Logger.LogDebug("Private/Intranet IP {IpAddress} is automatically allowed", ipAddress); + return SecurityCheckResult.Allowed(); + } + + // Check environment variable based filters + var isInWhitelist = Options.IpFiltering.Whitelist.Any(rule => IpAddressHelper.IsIpInRange(ipAddress, rule)); + var isInBlacklist = Options.IpFiltering.Blacklist.Any(rule => IpAddressHelper.IsIpInRange(ipAddress, rule)); + + var isAllowed = Options.IpFiltering.Mode.Equals("restrictive", StringComparison.OrdinalIgnoreCase) + ? isInWhitelist && !isInBlacklist + : !isInBlacklist; + + if (!isAllowed) + { + Logger.LogWarning("IP {IpAddress} blocked by IP filter rules", ipAddress); + return SecurityCheckResult.Denied("IP address not allowed"); + } + + // Check database-based IP filters (service-specific implementation) + return await CheckDatabaseIpFilterAsync(ipAddress); + } + + /// + /// Checks database-based IP filters. Override in derived classes to use the appropriate IP filter service. + /// + protected virtual Task CheckDatabaseIpFilterAsync(string ipAddress) + { + return Task.FromResult(SecurityCheckResult.Allowed()); + } + + /// + /// Checks if a path is excluded from security checks + /// + protected static bool IsPathExcluded(string path, List excludedPaths) + { + return excludedPaths.Any(excluded => path.StartsWith(excluded, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Gets the client IP address from the request + /// + protected static string GetClientIpAddress(HttpContext context) + { + return IpAddressHelper.GetClientIpAddress(context); + } + + // โ”€โ”€โ”€ Cache Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// + /// Gets a value from distributed or memory cache + /// + protected async Task GetCacheValueAsync(string key) where T : struct + { + if (Options.UseDistributedTracking && DistributedCache != null) + { + var cachedValue = await DistributedCache.GetStringAsync(key); + if (!string.IsNullOrEmpty(cachedValue)) + { + try + { + return JsonSerializer.Deserialize(cachedValue); + } + catch + { + return default; + } + } + } + else + { + return MemoryCache.Get(key); + } + + return default; + } + + /// + /// Gets a reference type value from distributed or memory cache + /// + protected async Task GetCacheObjectAsync(string key) where T : class + { + if (Options.UseDistributedTracking && DistributedCache != null) + { + var cachedValue = await DistributedCache.GetStringAsync(key); + if (!string.IsNullOrEmpty(cachedValue)) + { + try + { + return JsonSerializer.Deserialize(cachedValue); + } + catch + { + return null; + } + } + } + else + { + return MemoryCache.Get(key); + } + + return null; + } + + /// + /// Sets a value in distributed or memory cache + /// + protected async Task SetCacheValueAsync(string key, T value, TimeSpan expiration, bool sliding = false) + { + if (Options.UseDistributedTracking && DistributedCache != null) + { + var options = new DistributedCacheEntryOptions(); + if (sliding) + options.SlidingExpiration = expiration; + else + options.AbsoluteExpirationRelativeToNow = expiration; + + await DistributedCache.SetStringAsync(key, JsonSerializer.Serialize(value), options); + } + else + { + if (sliding) + MemoryCache.Set(key, value, new MemoryCacheEntryOptions { SlidingExpiration = expiration }); + else + MemoryCache.Set(key, value, expiration); + } + } + + /// + /// Removes a value from distributed or memory cache + /// + protected async Task RemoveCacheValueAsync(string key) + { + if (Options.UseDistributedTracking && DistributedCache != null) + { + await DistributedCache.RemoveAsync(key); + } + else + { + MemoryCache.Remove(key); + } + } + + private static string MaskKey(string key) + { + if (string.IsNullOrEmpty(key)) return ""; + return key.Length > 10 ? key[..10] + "..." : key; + } + } +} diff --git a/Tests/ConduitLLM.Benchmarks/ConduitLLM.Benchmarks.csproj b/Tests/ConduitLLM.Benchmarks/ConduitLLM.Benchmarks.csproj index 650bfab89..67e05111d 100644 --- a/Tests/ConduitLLM.Benchmarks/ConduitLLM.Benchmarks.csproj +++ b/Tests/ConduitLLM.Benchmarks/ConduitLLM.Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/Tests/ConduitLLM.IntegrationTests/ConduitLLM.IntegrationTests.csproj b/Tests/ConduitLLM.IntegrationTests/ConduitLLM.IntegrationTests.csproj index 98040f876..7d21ed4ad 100644 --- a/Tests/ConduitLLM.IntegrationTests/ConduitLLM.IntegrationTests.csproj +++ b/Tests/ConduitLLM.IntegrationTests/ConduitLLM.IntegrationTests.csproj @@ -9,24 +9,24 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - + + + + + - - - - + + + + diff --git a/Tests/ConduitLLM.IntegrationTests/Core/TestHelpers.cs b/Tests/ConduitLLM.IntegrationTests/Core/TestHelpers.cs index 0ee89d164..c80a6466e 100644 --- a/Tests/ConduitLLM.IntegrationTests/Core/TestHelpers.cs +++ b/Tests/ConduitLLM.IntegrationTests/Core/TestHelpers.cs @@ -302,7 +302,7 @@ public static async Task GenerateMarkdownReport( sb.AppendLine(); } - if (context.Errors.Count() > 0) + if (context.Errors.Any()) { sb.AppendLine("**Errors:**"); foreach (var error in context.Errors) diff --git a/Tests/ConduitLLM.IntegrationTests/Infrastructure/CriticalPathTestBase.cs b/Tests/ConduitLLM.IntegrationTests/Infrastructure/CriticalPathTestBase.cs new file mode 100644 index 000000000..4d8fa0e7c --- /dev/null +++ b/Tests/ConduitLLM.IntegrationTests/Infrastructure/CriticalPathTestBase.cs @@ -0,0 +1,380 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using ConduitLLM.IntegrationTests.Core; + +namespace ConduitLLM.IntegrationTests.Infrastructure; + +/// +/// Base class for critical path integration tests. +/// Extends ProviderIntegrationTestBase with Redis fixture support and common assertion helpers. +/// +public abstract class CriticalPathTestBase : IClassFixture, IClassFixture +{ + protected readonly TestFixture _fixture; + protected readonly RedisTestContainerFixture _redisFixture; + protected readonly ILogger _logger; + protected readonly ConduitApiClient _apiClient; + protected readonly TestConfiguration _config; + protected readonly TestContext _context; + + // Financial precision for billing verification (one-millionth of a dollar) + protected const decimal BillingTolerance = 0.000001m; + + protected CriticalPathTestBase(TestFixture fixture, RedisTestContainerFixture redisFixture) + { + _fixture = fixture; + _redisFixture = redisFixture; + _logger = CreateLogger(); + _apiClient = _fixture.ServiceProvider.GetRequiredService(); + _config = _fixture.Configuration; + _context = new TestContext(); + } + + protected abstract ILogger CreateLogger(); + + /// + /// Creates a virtual key group and virtual key with specified rate limits. + /// Returns the virtual key string for use in API requests. + /// + protected async Task<(string virtualKey, int groupId, decimal initialBalance)> CreateRateLimitedKeyAsync( + int? rpm = null, + int? rpd = null, + decimal initialCredit = 10.00m) + { + // Create virtual key group with initial credit + var createGroupRequest = new CreateVirtualKeyGroupRequest + { + GroupName = $"{_config.Defaults.TestPrefix}CriticalPath_{_context.TestRunId}", + InitialBalance = initialCredit + }; + + var groupResponse = await _apiClient.AdminPostAsync( + "/api/VirtualKeyGroups", + createGroupRequest); + + groupResponse.Success.Should().BeTrue($"Virtual key group creation should succeed: {groupResponse.Error}"); + var groupId = groupResponse.Data!.Id; + var balance = groupResponse.Data.Balance; + + // Create virtual key with rate limits + var createKeyRequest = new CreateVirtualKeyRequest + { + KeyName = $"{_config.Defaults.TestPrefix}CriticalPath_Key_{_context.TestRunId}", + VirtualKeyGroupId = groupId, + RateLimitRpm = rpm, + RateLimitRpd = rpd + }; + + var keyResponse = await _apiClient.AdminPostAsync( + "/api/VirtualKeys", + createKeyRequest); + + keyResponse.Success.Should().BeTrue($"Virtual key creation should succeed: {keyResponse.Error}"); + var virtualKey = keyResponse.Data!.VirtualKey; + + _logger.LogInformation( + "Created rate-limited key: GroupId={GroupId}, Balance=${Balance}, RPM={RPM}, RPD={RPD}", + groupId, balance, rpm, rpd); + + return (virtualKey, groupId, balance); + } + + /// + /// Flushes batch spending updates and returns the updated group balance. + /// + protected async Task FlushAndGetBalanceAsync(int groupId) + { + // Trigger batch spend flush + var flushResponse = await _apiClient.AdminPostAsync( + "/api/batch-spending/flush", + new { reason = "Critical path test balance verification", priority = "Normal" }); + + if (!flushResponse.Success) + { + _logger.LogWarning("Flush request failed: {Error}, falling back to delay", flushResponse.Error); + await Task.Delay(3000); + } + else + { + // Brief delay for async flush to complete + await Task.Delay(1000); + } + + // Get updated balance + var balanceResponse = await _apiClient.AdminGetAsync( + $"/api/VirtualKeyGroups/{groupId}"); + + balanceResponse.Success.Should().BeTrue($"Failed to fetch updated balance: {balanceResponse.Error}"); + return balanceResponse.Data!.Balance; + } + + /// + /// Asserts that no spend was deducted from the virtual key group. + /// Useful for verifying that error responses are not billed. + /// + protected async Task AssertNoSpendDeductionAsync(int groupId, decimal expectedBalance) + { + var actualBalance = await FlushAndGetBalanceAsync(groupId); + + actualBalance.Should().Be( + expectedBalance, + $"Balance should remain unchanged at ${expectedBalance:F6}, but was ${actualBalance:F6}"); + + _logger.LogInformation("Verified no spend deduction: Balance=${Balance}", actualBalance); + } + + /// + /// Asserts that spend was deducted from the virtual key group. + /// + protected async Task AssertSpendDeductedAsync(int groupId, decimal initialBalance, decimal minExpectedDeduction) + { + var actualBalance = await FlushAndGetBalanceAsync(groupId); + var actualDeduction = initialBalance - actualBalance; + + actualDeduction.Should().BeGreaterThanOrEqualTo( + minExpectedDeduction, + $"Deduction ${actualDeduction:F6} should be at least ${minExpectedDeduction:F6}"); + + _logger.LogInformation( + "Verified spend deduction: Initial=${Initial}, Current=${Current}, Deducted=${Deducted}", + initialBalance, actualBalance, actualDeduction); + } + + /// + /// Asserts that the deducted amount matches the expected cost within billing tolerance. + /// + protected async Task AssertExactSpendDeductionAsync(int groupId, decimal initialBalance, decimal expectedDeduction) + { + var actualBalance = await FlushAndGetBalanceAsync(groupId); + var actualDeduction = initialBalance - actualBalance; + + Math.Abs(actualDeduction - expectedDeduction).Should().BeLessThan( + BillingTolerance, + $"Billing discrepancy: Expected ${expectedDeduction:F6}, Actually deducted ${actualDeduction:F6}"); + + _logger.LogInformation( + "Verified exact spend: Expected=${Expected}, Actual=${Actual}", + expectedDeduction, actualDeduction); + } + + /// + /// Waits for all services to be healthy before running tests. + /// + protected async Task WaitForServicesAsync() + { + var servicesReady = await TestHelpers.HealthChecks.WaitForServicesAsync(_config, _logger); + servicesReady.Should().BeTrue("All services should be ready before running tests"); + } + + /// + /// Disables a virtual key by updating it via the Admin API. + /// + protected async Task DisableVirtualKeyAsync(string virtualKey) + { + // Extract the key hash from the virtual key to find its ID + // Virtual keys are in format "cvk_xxxx" and the hash is stored in the database + var response = await _apiClient.AdminGetAsync>("/api/VirtualKeys"); + response.Success.Should().BeTrue($"Failed to list virtual keys: {response.Error}"); + + var keyInfo = response.Data?.FirstOrDefault(k => k.KeyName.Contains(_context.TestRunId)); + if (keyInfo != null) + { + // Disable the key + var updateResponse = await _apiClient.AdminPutAsync( + $"/api/VirtualKeys/{keyInfo.Id}", + new { isEnabled = false }); + + updateResponse.Success.Should().BeTrue($"Failed to disable virtual key: {updateResponse.Error}"); + _logger.LogInformation("Disabled virtual key: {KeyId}", keyInfo.Id); + } + } + + // ===================================================== + // Provider Setup Helper Methods + // ===================================================== + + /// + /// Converts a provider type string to its corresponding enum value. + /// + protected static int GetProviderTypeEnum(string providerType) + { + return providerType.ToLower() switch + { + "openai" => 1, + "groq" => 2, + "replicate" => 3, + "fireworks" => 4, + "openaicompatible" => 5, + "minimax" => 6, + "ultravox" => 7, + "elevenlabs" => 8, + "cerebras" => 9, + "sambanova" => 10, + "deepinfra" => 11, + _ => throw new InvalidOperationException($"Unknown provider type: {providerType}") + }; + } + + /// + /// Creates a provider with API key credentials. + /// + /// The provider configuration. + /// Optional API key override (e.g., for testing invalid keys). + /// Optional suffix for the provider name (defaults to "Test"). + /// The created provider's ID. + protected async Task SetupProviderAsync( + ProviderConfig providerConfig, + string? overrideApiKey = null, + string? nameSuffix = null) + { + var providerTypeEnum = GetProviderTypeEnum(providerConfig.Provider.Type); + var suffix = nameSuffix ?? "Test"; + + var createProviderRequest = new CreateProviderRequest + { + ProviderName = $"{_config.Defaults.TestPrefix}{suffix}_{_context.TestRunId}", + ProviderType = providerTypeEnum, + BaseUrl = providerConfig.Provider.BaseUrl, + IsEnabled = true + }; + + var providerResponse = await _apiClient.AdminPostAsync( + "/api/ProviderCredentials", + createProviderRequest); + providerResponse.Success.Should().BeTrue($"Provider creation failed: {providerResponse.Error}"); + + // Add provider key (use override if provided, otherwise use config) + var apiKey = overrideApiKey ?? providerConfig.Provider.ApiKey; + var createKeyRequest = new CreateProviderKeyRequest + { + ApiKey = apiKey, + KeyName = $"{_config.Defaults.TestPrefix}Key_{_context.TestRunId}", + IsPrimary = true + }; + + var keyResponse = await _apiClient.AdminPostAsync( + $"/api/ProviderCredentials/{providerResponse.Data!.Id}/keys", + createKeyRequest); + keyResponse.Success.Should().BeTrue($"Key creation failed: {keyResponse.Error}"); + + _logger.LogInformation("Created provider: Id={ProviderId}, Name={Name}", + providerResponse.Data.Id, createProviderRequest.ProviderName); + + return providerResponse.Data.Id; + } + + /// + /// Creates a model mapping for a provider. + /// Stores the mapping ID and alias in _context for use by other methods. + /// + /// The provider ID to map to. + /// The provider configuration containing model info. + /// The model alias and mapping ID. + protected async Task<(string modelAlias, int mappingId)> SetupModelMappingAsync( + int providerId, + ProviderConfig providerConfig) + { + var modelConfig = providerConfig.Models[0]; + var modelAlias = $"{modelConfig.Alias}_{_context.TestRunId}"; + + var createMappingRequest = new CreateModelMappingRequest + { + ModelId = modelAlias, + ProviderId = providerId, + ProviderModelId = modelConfig.Actual, + SupportsChat = modelConfig.Capabilities.Chat, + SupportsStreaming = modelConfig.Capabilities.Streaming + }; + + var mappingResponse = await _apiClient.AdminPostAsync( + "/api/ModelProviderMapping", + createMappingRequest); + mappingResponse.Success.Should().BeTrue($"Model mapping failed: {mappingResponse.Error}"); + + // Store in context for other methods + _context.ModelMappingId = mappingResponse.Data!.Id; + _context.ModelAlias = modelAlias; + + _logger.LogInformation("Created model mapping: Alias={Alias}, MappingId={MappingId}", + modelAlias, mappingResponse.Data.Id); + + return (modelAlias, mappingResponse.Data.Id); + } + + /// + /// Creates a model cost configuration. + /// Requires SetupModelMappingAsync to have been called first. + /// + protected async Task SetupModelCostAsync(ProviderConfig providerConfig) + { + if (_context.ModelMappingId == null || _context.ModelAlias == null) + { + throw new InvalidOperationException("SetupModelMappingAsync must be called before SetupModelCostAsync"); + } + + var modelConfig = providerConfig.Models[0]; + + var createCostRequest = new CreateModelCostRequest + { + CostName = $"{_context.ModelAlias}_cost", + ModelProviderMappingIds = new List { _context.ModelMappingId.Value }, + InputCostPerMillionTokens = modelConfig.Cost.InputPerMillion, + OutputCostPerMillionTokens = modelConfig.Cost.OutputPerMillion + }; + + var costResponse = await _apiClient.AdminPostAsync( + "/api/ModelCosts", + createCostRequest); + costResponse.Success.Should().BeTrue($"Model cost creation failed: {costResponse.Error}"); + + _logger.LogInformation("Created model cost: Name={CostName}", createCostRequest.CostName); + } + + /// + /// Disables a provider by ID. + /// + protected async Task DisableProviderAsync(int providerId) + { + var updateResponse = await _apiClient.AdminPutAsync( + $"/api/ProviderCredentials/{providerId}", + new { isEnabled = false }); + + updateResponse.Success.Should().BeTrue($"Failed to disable provider: {updateResponse.Error}"); + _logger.LogInformation("Disabled provider: {ProviderId}", providerId); + } + + /// + /// Disables a model mapping by ID. + /// + protected async Task DisableModelMappingAsync(int mappingId) + { + var updateResponse = await _apiClient.AdminPutAsync( + $"/api/ModelProviderMapping/{mappingId}", + new { isEnabled = false }); + + updateResponse.Success.Should().BeTrue($"Failed to disable mapping: {updateResponse.Error}"); + _logger.LogInformation("Disabled model mapping: {MappingId}", mappingId); + } +} + +/// +/// DTO for listing virtual keys. +/// +public class VirtualKeyListItem +{ + public int Id { get; set; } + public string KeyName { get; set; } = ""; + public bool IsEnabled { get; set; } + public int VirtualKeyGroupId { get; set; } +} + +/// +/// xUnit collection definition for critical path tests that share Redis. +/// +[CollectionDefinition("Critical Path")] +public class CriticalPathCollection : ICollectionFixture, ICollectionFixture +{ + // This class has no code - it's used to wire up fixtures with xUnit collection +} diff --git a/Tests/ConduitLLM.IntegrationTests/Infrastructure/StreamingResponseParser.cs b/Tests/ConduitLLM.IntegrationTests/Infrastructure/StreamingResponseParser.cs new file mode 100644 index 000000000..83e96f53e --- /dev/null +++ b/Tests/ConduitLLM.IntegrationTests/Infrastructure/StreamingResponseParser.cs @@ -0,0 +1,278 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace ConduitLLM.IntegrationTests.Infrastructure; + +/// +/// Utility class for parsing Server-Sent Events (SSE) streaming responses. +/// Used for testing streaming chat completions. +/// +public static class StreamingResponseParser +{ + /// + /// Parses an SSE stream and yields events as they arrive. + /// + /// The response stream from a streaming chat completion request. + /// Optional cancellation token. + /// An async enumerable of SSE events. + public static async IAsyncEnumerable ParseAsync( + Stream stream, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using var reader = new StreamReader(stream); + string? currentEventType = null; + + while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrEmpty(line)) + { + // Empty line - end of event + currentEventType = null; + continue; + } + + if (line.StartsWith("event:")) + { + currentEventType = line[6..].Trim(); + } + else if (line.StartsWith("data:")) + { + var data = line[5..].Trim(); + if (data == "[DONE]") + { + yield return new SSEEvent + { + EventType = "done", + Data = data, + IsDone = true + }; + yield break; + } + + yield return new SSEEvent + { + EventType = currentEventType, + Data = data, + IsDone = false + }; + + // Reset event type after yielding + currentEventType = null; + } + } + } + + /// + /// Collects all events from a stream into a list. + /// + public static async Task> CollectAllAsync( + Stream stream, + CancellationToken cancellationToken = default) + { + var events = new List(); + await foreach (var evt in ParseAsync(stream, cancellationToken)) + { + events.Add(evt); + } + return events; + } + + /// + /// Extracts the aggregated content from all streaming chunks. + /// + public static string ExtractContent(IEnumerable events) + { + var content = new System.Text.StringBuilder(); + + foreach (var evt in events.Where(e => !e.IsDone && e.EventType != "metrics-final")) + { + try + { + using var doc = JsonDocument.Parse(evt.Data); + var root = doc.RootElement; + + // Handle standard chat completion chunks + if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0) + { + var choice = choices[0]; + if (choice.TryGetProperty("delta", out var delta) && + delta.TryGetProperty("content", out var contentElement)) + { + var chunk = contentElement.GetString(); + if (!string.IsNullOrEmpty(chunk)) + { + content.Append(chunk); + } + } + } + // Handle reasoning events + else if (evt.EventType == "reasoning" && + root.TryGetProperty("content", out var reasoningContent)) + { + var chunk = reasoningContent.GetString(); + if (!string.IsNullOrEmpty(chunk)) + { + content.Append(chunk); + } + } + } + catch (JsonException) + { + // Skip malformed JSON chunks + } + } + + return content.ToString(); + } + + /// + /// Extracts usage information from the final metrics event or the last chunk. + /// + public static StreamingUsage? ExtractFinalUsage(IEnumerable events) + { + // First, try to find metrics-final event (Conduit-specific) + var metricsFinalEvent = events.FirstOrDefault(e => e.EventType == "metrics-final"); + if (metricsFinalEvent != null) + { + try + { + using var doc = JsonDocument.Parse(metricsFinalEvent.Data); + var root = doc.RootElement; + + return new StreamingUsage + { + PromptTokens = GetIntOrNull(root, "prompt_tokens") ?? 0, + CompletionTokens = GetIntOrNull(root, "completion_tokens") ?? 0, + TotalTokens = GetIntOrNull(root, "total_tokens") ?? 0, + TokensPerSecond = GetDoubleOrNull(root, "tokens_per_second") + }; + } + catch (JsonException) + { + // Fall through to try other methods + } + } + + // Try to find usage in the last non-done chunk (OpenAI style) + var lastChunk = events + .Where(e => !e.IsDone && e.EventType != "metrics-final") + .LastOrDefault(); + + if (lastChunk != null) + { + try + { + using var doc = JsonDocument.Parse(lastChunk.Data); + var root = doc.RootElement; + + if (root.TryGetProperty("usage", out var usage)) + { + return new StreamingUsage + { + PromptTokens = GetIntOrNull(usage, "prompt_tokens") ?? 0, + CompletionTokens = GetIntOrNull(usage, "completion_tokens") ?? 0, + TotalTokens = GetIntOrNull(usage, "total_tokens") ?? 0 + }; + } + } + catch (JsonException) + { + // No usage found + } + } + + return null; + } + + /// + /// Extracts tool call events from the stream. + /// + public static List ExtractToolCalls(IEnumerable events) + { + var toolCalls = new List(); + + foreach (var evt in events.Where(e => e.EventType == "tool-executing")) + { + try + { + using var doc = JsonDocument.Parse(evt.Data); + var root = doc.RootElement; + + toolCalls.Add(new ToolCallEvent + { + ToolName = root.TryGetProperty("tool_name", out var name) ? name.GetString() : null, + State = root.TryGetProperty("state", out var state) ? state.GetString() : null, + Arguments = root.TryGetProperty("arguments", out var args) ? args.ToString() : null + }); + } + catch (JsonException) + { + // Skip malformed tool call events + } + } + + return toolCalls; + } + + private static int? GetIntOrNull(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number) + { + return prop.GetInt32(); + } + return null; + } + + private static double? GetDoubleOrNull(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number) + { + return prop.GetDouble(); + } + return null; + } +} + +/// +/// Represents a Server-Sent Event from a streaming response. +/// +public class SSEEvent +{ + /// + /// The event type (e.g., "content", "reasoning", "tool-executing", "metrics-final"). + /// Null for standard data-only events. + /// + public string? EventType { get; set; } + + /// + /// The JSON data payload of the event. + /// + public string Data { get; set; } = ""; + + /// + /// True if this is the [DONE] marker indicating end of stream. + /// + public bool IsDone { get; set; } +} + +/// +/// Usage information extracted from streaming responses. +/// +public class StreamingUsage +{ + public int PromptTokens { get; set; } + public int CompletionTokens { get; set; } + public int TotalTokens { get; set; } + public double? TokensPerSecond { get; set; } +} + +/// +/// Tool call information from streaming responses. +/// +public class ToolCallEvent +{ + public string? ToolName { get; set; } + public string? State { get; set; } // "started", "completed" + public string? Arguments { get; set; } +} diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/CerebrasEndToEndTest.cs b/Tests/ConduitLLM.IntegrationTests/Tests/CerebrasEndToEndTest.cs index e8e06886c..7d488db06 100644 --- a/Tests/ConduitLLM.IntegrationTests/Tests/CerebrasEndToEndTest.cs +++ b/Tests/ConduitLLM.IntegrationTests/Tests/CerebrasEndToEndTest.cs @@ -73,7 +73,7 @@ public async Task CerebrasProvider_BasicChat_ShouldWork() reportGenerated = true; // Now check if there were errors and fail the test if needed - if (_context.Errors.Count() > 0) + if (_context.Errors.Any()) { var errorMessage = string.Join("; ", _context.Errors); _specificLogger.LogError("Test completed with errors: {Errors}", errorMessage); diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/AuthenticationIntegrationTests.cs b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/AuthenticationIntegrationTests.cs new file mode 100644 index 000000000..905c98140 --- /dev/null +++ b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/AuthenticationIntegrationTests.cs @@ -0,0 +1,345 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +using ConduitLLM.IntegrationTests.Core; +using ConduitLLM.IntegrationTests.Infrastructure; + +namespace ConduitLLM.IntegrationTests.Tests.CriticalPath; + +/// +/// Integration tests for Authentication and Authorization critical path. +/// Tests virtual key lifecycle, rate limiting, and spend tracking. +/// +[Collection("Critical Path")] +[Trait("Category", "Integration")] +[Trait("CriticalPath", "true")] +public class AuthenticationIntegrationTests : CriticalPathTestBase +{ + private readonly ITestOutputHelper _output; + private readonly ILogger _specificLogger; + + public AuthenticationIntegrationTests( + TestFixture fixture, + RedisTestContainerFixture redisFixture, + ITestOutputHelper output) + : base(fixture, redisFixture) + { + _output = output; + _specificLogger = _fixture.ServiceProvider.GetRequiredService>(); + } + + protected override ILogger CreateLogger() + { + return _fixture.ServiceProvider.GetRequiredService>(); + } + + // ===================================================== + // Virtual Key Lifecycle Tests + // ===================================================== + + [Fact(DisplayName = "Virtual Key - Create and validate succeeds with valid credentials")] + public async Task VirtualKey_CreateAndValidate_SucceedsWithValidCredentials() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Act - Make a simple health check request with the virtual key + // This validates the key is accepted by the authentication handler + using var request = new HttpRequestMessage(HttpMethod.Get, "/v1/models"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", virtualKey); + + var response = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + + // Assert + response.Success.Should().BeTrue("Valid virtual key should authenticate successfully"); + response.StatusCode.Should().Be(200); + + _output.WriteLine($"Virtual key authentication successful: {virtualKey[..20]}..."); + } + + [Fact(DisplayName = "Virtual Key - Disabled key returns 401 Unauthorized")] + public async Task VirtualKey_Disabled_ReturnsUnauthorized() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Disable the key + await DisableVirtualKeyAsync(virtualKey); + + // Act - Try to use the disabled key + var response = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + + // Assert + response.Success.Should().BeFalse("Disabled virtual key should be rejected"); + response.StatusCode.Should().Be(401, "Disabled key should return 401 Unauthorized"); + + _output.WriteLine("Disabled key correctly rejected with 401"); + } + + [Fact(DisplayName = "Virtual Key - Zero balance returns 402 Payment Required")] + public async Task VirtualKey_ZeroBalance_Returns402PaymentRequired() + { + // Arrange + await WaitForServicesAsync(); + + // Create a key with zero initial balance + var createGroupRequest = new CreateVirtualKeyGroupRequest + { + GroupName = $"{_config.Defaults.TestPrefix}ZeroBalance_{_context.TestRunId}", + InitialBalance = 0.00m + }; + + var groupResponse = await _apiClient.AdminPostAsync( + "/api/VirtualKeyGroups", + createGroupRequest); + groupResponse.Success.Should().BeTrue(); + + var createKeyRequest = new CreateVirtualKeyRequest + { + KeyName = $"{_config.Defaults.TestPrefix}ZeroBalanceKey_{_context.TestRunId}", + VirtualKeyGroupId = groupResponse.Data!.Id + }; + + var keyResponse = await _apiClient.AdminPostAsync( + "/api/VirtualKeys", + createKeyRequest); + keyResponse.Success.Should().BeTrue(); + + var virtualKey = keyResponse.Data!.VirtualKey; + + // Act - Try to make a chat completion request (which requires balance) + var chatRequest = new ChatCompletionRequest + { + Model = "test-model", // This model doesn't need to exist for 402 check + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync("/v1/chat/completions", chatRequest, virtualKey); + + // Assert + // Note: The exact behavior depends on whether balance check happens before or after model validation + // Accept either 402 (balance check first) or 404 (model check first) + response.Success.Should().BeFalse("Zero balance key should be rejected for chat requests"); + response.StatusCode.Should().BeOneOf(new[] { 402, 404 }, + "Zero balance should return 402 Payment Required or 404 if model is checked first"); + + _output.WriteLine($"Zero balance key request returned: {response.StatusCode}"); + } + + // ===================================================== + // Rate Limit Enforcement Tests + // ===================================================== + + [Fact(DisplayName = "Rate Limit RPM - Exceeds limit returns 429 with Retry-After")] + public async Task RateLimitRPM_ExceedsLimit_Returns429WithRetryAfter() + { + // Arrange + await WaitForServicesAsync(); + + // Create a key with a very low RPM limit (2 requests per minute) + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(rpm: 2, initialCredit: 10.00m); + + // Act - Make requests exceeding the limit + var responses = new List>(); + + for (int i = 0; i < 4; i++) // 4 requests, limit is 2 + { + var response = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + responses.Add(response); + _output.WriteLine($"Request {i + 1}: Status={response.StatusCode}"); + + // Small delay to ensure requests are processed + await Task.Delay(100); + } + + // Assert + // First 2 requests should succeed, subsequent requests should be rate limited + var successfulRequests = responses.Count(r => r.StatusCode == 200); + var rateLimitedRequests = responses.Count(r => r.StatusCode == 429); + + successfulRequests.Should().BeGreaterThanOrEqualTo(2, "At least first 2 requests should succeed"); + rateLimitedRequests.Should().BeGreaterThan(0, "Some requests should be rate limited"); + + _output.WriteLine($"Successful: {successfulRequests}, Rate limited: {rateLimitedRequests}"); + } + + [Fact(DisplayName = "Rate Limit RPD - Exceeds limit returns 429")] + public async Task RateLimitRPD_ExceedsLimit_Returns429() + { + // Arrange + await WaitForServicesAsync(); + + // Create a key with a very low RPD limit (3 requests per day) + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(rpd: 3, initialCredit: 10.00m); + + // Act - Make requests exceeding the limit + var responses = new List>(); + + for (int i = 0; i < 5; i++) // 5 requests, limit is 3 + { + var response = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + responses.Add(response); + _output.WriteLine($"Request {i + 1}: Status={response.StatusCode}"); + + await Task.Delay(100); + } + + // Assert + var successfulRequests = responses.Count(r => r.StatusCode == 200); + var rateLimitedRequests = responses.Count(r => r.StatusCode == 429); + + successfulRequests.Should().BeGreaterThanOrEqualTo(3, "At least first 3 requests should succeed"); + rateLimitedRequests.Should().BeGreaterThan(0, "Some requests should be rate limited (RPD)"); + + _output.WriteLine($"RPD Test - Successful: {successfulRequests}, Rate limited: {rateLimitedRequests}"); + } + + // ===================================================== + // Spend Tracking Tests + // ===================================================== + + [Fact(DisplayName = "Spend Tracking - After chat request deducts correct amount")] + public async Task SpendTracking_AfterChatRequest_DeductsCorrectAmount() + { + // Arrange + await WaitForServicesAsync(); + + // This test requires a real provider to be configured + // Skip if no active providers + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + + // Set up provider infrastructure similar to ProviderBillingIntegrationTests + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + // Create provider setup using the pattern from existing tests + // (This requires the provider infrastructure to be set up) + // For now, we'll test with an existing model if one exists + + _output.WriteLine($"Testing spend tracking with provider: {providerName}"); + _output.WriteLine($"Initial balance: ${initialBalance:F6}"); + + // Note: Full spend tracking test requires provider setup + // This is covered more thoroughly in ProviderBillingIntegrationTests + // Here we just verify the balance query mechanism works + + var balance = await FlushAndGetBalanceAsync(groupId); + balance.Should().Be(initialBalance, "Balance should be unchanged with no requests made"); + + _output.WriteLine($"Balance after flush: ${balance:F6}"); + } + + [Fact(DisplayName = "Spend Tracking - Batch flush updates balance correctly")] + public async Task SpendTracking_BatchFlush_UpdatesBalanceCorrectly() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 50.00m); + + // Act - Trigger batch flush + var balanceAfterFlush = await FlushAndGetBalanceAsync(groupId); + + // Assert + balanceAfterFlush.Should().Be(initialBalance, + "Balance should remain unchanged when no billable requests were made"); + + _output.WriteLine($"Batch flush verified: Balance=${balanceAfterFlush:F6}"); + } + + // ===================================================== + // Authentication Source Tests + // ===================================================== + + [Fact(DisplayName = "Auth - Bearer token validates successfully")] + public async Task AuthFromBearerToken_ValidatesSuccessfully() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Act - Use Bearer token authentication + var response = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + + // Assert + response.Success.Should().BeTrue("Bearer token authentication should work"); + response.StatusCode.Should().Be(200); + + _output.WriteLine("Bearer token authentication verified"); + } + + [Fact(DisplayName = "Auth - X-API-Key header validates successfully")] + public async Task AuthFromXApiKeyHeader_ValidatesSuccessfully() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Act - Use X-API-Key header instead of Bearer token + using var httpClient = new HttpClient + { + BaseAddress = new Uri(_config.Environment.CoreApiUrl) + }; + httpClient.DefaultRequestHeaders.Add("X-API-Key", virtualKey); + + var response = await httpClient.GetAsync("/v1/models"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK, "X-API-Key header authentication should work"); + + _output.WriteLine("X-API-Key header authentication verified"); + } + + [Fact(DisplayName = "Auth - Missing authentication returns 401")] + public async Task AuthMissing_Returns401Unauthorized() + { + // Arrange + await WaitForServicesAsync(); + + // Act - Make request without any authentication + using var httpClient = new HttpClient + { + BaseAddress = new Uri(_config.Environment.CoreApiUrl) + }; + + var response = await httpClient.GetAsync("/v1/models"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "Request without authentication should return 401"); + + _output.WriteLine("Missing authentication correctly rejected with 401"); + } + + [Fact(DisplayName = "Auth - Invalid token format returns 401")] + public async Task AuthInvalidToken_Returns401Unauthorized() + { + // Arrange + await WaitForServicesAsync(); + + // Act - Use an invalid token format + var response = await _apiClient.CoreGetAsync("/v1/models", "invalid-token-format"); + + // Assert + response.Success.Should().BeFalse("Invalid token should be rejected"); + response.StatusCode.Should().Be(401, "Invalid token should return 401"); + + _output.WriteLine("Invalid token correctly rejected with 401"); + } +} diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/CachingIntegrationTests.cs b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/CachingIntegrationTests.cs new file mode 100644 index 000000000..a45b563cf --- /dev/null +++ b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/CachingIntegrationTests.cs @@ -0,0 +1,253 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +using ConduitLLM.IntegrationTests.Core; +using ConduitLLM.IntegrationTests.Infrastructure; + +namespace ConduitLLM.IntegrationTests.Tests.CriticalPath; + +/// +/// Integration tests for Caching Behavior critical path. +/// Tests Redis circuit breaker, graceful degradation, and cache operations. +/// +[Collection("Critical Path")] +[Trait("Category", "Integration")] +[Trait("CriticalPath", "true")] +public class CachingIntegrationTests : CriticalPathTestBase +{ + private readonly ITestOutputHelper _output; + private readonly ILogger _specificLogger; + + public CachingIntegrationTests( + TestFixture fixture, + RedisTestContainerFixture redisFixture, + ITestOutputHelper output) + : base(fixture, redisFixture) + { + _output = output; + _specificLogger = _fixture.ServiceProvider.GetRequiredService>(); + } + + protected override ILogger CreateLogger() + { + return _fixture.ServiceProvider.GetRequiredService>(); + } + + // ===================================================== + // Redis Circuit Breaker Tests + // ===================================================== + + [Fact(DisplayName = "Redis Unavailable - Health endpoints still work")] + public async Task RedisUnavailable_HealthEndpointsStillWork() + { + // Arrange + await WaitForServicesAsync(); + _redisFixture.IsRunning.Should().BeTrue("Redis should be running before test"); + + // Simulate Redis failure + _output.WriteLine("Stopping Redis container..."); + await _redisFixture.StopAsync(); + _redisFixture.IsRunning.Should().BeFalse("Redis should be stopped"); + + try + { + // Small delay for circuit breaker to detect failure + await Task.Delay(1000); + + // Act - Health endpoints should still respond + using var httpClient = new HttpClient + { + BaseAddress = new Uri(_config.Environment.CoreApiUrl) + }; + + var healthResponse = await httpClient.GetAsync("/health"); + + // Assert - Health endpoints bypass Redis requirement + // Note: The exact behavior depends on the application's circuit breaker configuration + // Health endpoints typically bypass Redis checks + _output.WriteLine($"Health endpoint response: {healthResponse.StatusCode}"); + + // The health endpoint may return 200 (healthy) or 503 (degraded) depending on configuration + // What's important is that it responds at all + healthResponse.Should().NotBeNull("Health endpoint should respond even with Redis down"); + } + finally + { + // Cleanup - Restart Redis + _output.WriteLine("Restarting Redis container..."); + await _redisFixture.RestartAsync(); + await Task.Delay(2000); // Allow time for reconnection + _redisFixture.IsRunning.Should().BeTrue("Redis should be restarted after test"); + } + } + + [Fact(DisplayName = "Redis Unavailable - Circuit opens after failures")] + public async Task RedisUnavailable_CircuitOpens_Returns503() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Stop Redis to trigger circuit breaker + _output.WriteLine("Stopping Redis to trigger circuit breaker..."); + await _redisFixture.StopAsync(); + + try + { + // Give some time for circuit breaker to detect failure + await Task.Delay(2000); + + // Act - Make requests that depend on Redis + // Note: The actual behavior depends on what the application does when Redis is down + // Some endpoints may use Redis for rate limiting or caching + + // For this test, we check that the application handles Redis failure gracefully + var response = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + + // Assert + // The application should either: + // 1. Return 503 Service Unavailable (circuit breaker open) + // 2. Continue working with degraded functionality (graceful degradation) + // 3. Return 200 if the endpoint doesn't require Redis + + _output.WriteLine($"Response with Redis down: {response.StatusCode}"); + + // Accept any non-crash response as success for this test + // The key is that the application doesn't throw an unhandled exception + response.StatusCode.Should().BeOneOf(new[] { 200, 503, 500 }, + "Application should handle Redis failure gracefully"); + } + finally + { + // Cleanup + _output.WriteLine("Restarting Redis..."); + await _redisFixture.RestartAsync(); + await Task.Delay(2000); + } + } + + [Fact(DisplayName = "Redis Recovery - Circuit closes after successful reconnection")] + public async Task RedisRecovery_CircuitCloses_NormalOperation() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Verify normal operation first + var initialResponse = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + initialResponse.Success.Should().BeTrue("Initial request should succeed"); + _output.WriteLine("Initial request successful"); + + // Stop and restart Redis + _output.WriteLine("Stopping Redis..."); + await _redisFixture.StopAsync(); + await Task.Delay(2000); + + _output.WriteLine("Restarting Redis..."); + await _redisFixture.RestartAsync(); + await Task.Delay(3000); // Give time for circuit breaker to recover + + // Act - Make request after Redis recovery + var recoveryResponse = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + + // Assert + recoveryResponse.Success.Should().BeTrue("Request should succeed after Redis recovery"); + recoveryResponse.StatusCode.Should().Be(200); + + _output.WriteLine("Recovery successful - circuit closed"); + } + + // ===================================================== + // Graceful Degradation Tests + // ===================================================== + + [Fact(DisplayName = "Redis Down - Requests still processed with graceful fallback")] + public async Task RedisDown_RequestsStillProcessed_GracefulFallback() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + // Set up provider while Redis is up + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Caching"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Stop Redis + _output.WriteLine("Stopping Redis for graceful degradation test..."); + await _redisFixture.StopAsync(); + + try + { + await Task.Delay(2000); + + // Act - Try to make a chat request + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + // With graceful degradation, the request should either: + // 1. Succeed (if rate limiting falls back to allow-all) + // 2. Fail with 503 (if Redis is required) + _output.WriteLine($"Response with Redis down: {response.StatusCode}"); + + // Document actual behavior + if (response.Success) + { + _output.WriteLine("Application handled Redis failure gracefully - request succeeded"); + } + else + { + _output.WriteLine($"Application returned error: {response.Error}"); + } + } + finally + { + // Cleanup + _output.WriteLine("Restarting Redis..."); + await _redisFixture.RestartAsync(); + await Task.Delay(2000); + } + } + + [Fact(DisplayName = "Redis Flush - Clears cached data for test isolation")] + public async Task RedisFlush_ClearsCachedData_TestIsolation() + { + // Arrange + await WaitForServicesAsync(); + _redisFixture.IsRunning.Should().BeTrue("Redis should be running"); + + // Act - Flush Redis + await _redisFixture.FlushAllAsync(); + + // Assert - System should still work after flush + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + var response = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + + response.Success.Should().BeTrue("System should work after Redis flush"); + + _output.WriteLine("Redis flush successful - test isolation verified"); + } +} diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/ProviderFailoverIntegrationTests.cs b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/ProviderFailoverIntegrationTests.cs new file mode 100644 index 000000000..7b94bf6e8 --- /dev/null +++ b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/ProviderFailoverIntegrationTests.cs @@ -0,0 +1,326 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +using ConduitLLM.IntegrationTests.Core; +using ConduitLLM.IntegrationTests.Infrastructure; + +namespace ConduitLLM.IntegrationTests.Tests.CriticalPath; + +/// +/// Integration tests for Provider Failover critical path. +/// Tests provider failure scenarios, circuit breaker behavior, and failover logic. +/// Uses real providers with failure simulation via invalid keys, disabled mappings, etc. +/// +[Collection("Critical Path")] +[Trait("Category", "Integration")] +[Trait("CriticalPath", "true")] +public class ProviderFailoverIntegrationTests : CriticalPathTestBase +{ + private readonly ITestOutputHelper _output; + private readonly ILogger _specificLogger; + + public ProviderFailoverIntegrationTests( + TestFixture fixture, + RedisTestContainerFixture redisFixture, + ITestOutputHelper output) + : base(fixture, redisFixture) + { + _output = output; + _specificLogger = _fixture.ServiceProvider.GetRequiredService>(); + } + + protected override ILogger CreateLogger() + { + return _fixture.ServiceProvider.GetRequiredService>(); + } + + // ===================================================== + // Provider Failure Simulation Tests + // ===================================================== + + [Fact(DisplayName = "Provider with invalid key - Fails gracefully with error response")] + public async Task ProviderWithInvalidKey_FailsGracefully() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Create provider with INVALID API key + var providerId = await SetupProviderAsync( + providerConfig, + overrideApiKey: "sk-invalid-key-that-will-fail-authentication-12345", + nameSuffix: "InvalidKey"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Act - Try to make a request to the provider with invalid key + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + response.Success.Should().BeFalse("Request to provider with invalid key should fail"); + response.StatusCode.Should().BeOneOf(new[] { 401, 403, 500, 502 }, + "Should return auth error or server error from provider"); + + _output.WriteLine($"Invalid key correctly handled: {response.StatusCode}"); + + // Verify no spend was deducted (error responses should not be billed) + await AssertNoSpendDeductionAsync(groupId, balance); + _output.WriteLine("Error correctly NOT billed"); + } + + [Fact(DisplayName = "Provider disabled - Returns 404 Not Found")] + public async Task ProviderDisabled_Returns404() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Create provider but disable it + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Failover"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Disable the provider + await DisableProviderAsync(providerId); + + // Act - Try to make a request + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + response.Success.Should().BeFalse("Request to disabled provider should fail"); + response.StatusCode.Should().BeOneOf(new[] { 404, 503 }, + "Disabled provider should return 404 or 503"); + + _output.WriteLine($"Disabled provider correctly handled: {response.StatusCode}"); + + // Verify no spend was deducted + await AssertNoSpendDeductionAsync(groupId, balance); + } + + [Fact(DisplayName = "Model mapping disabled - Returns 404 Not Found")] + public async Task ModelMappingDisabled_Returns404() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Failover"); + var (modelAlias, mappingId) = await SetupModelMappingAsync(providerId, providerConfig); + + // Disable the model mapping + await DisableModelMappingAsync(mappingId); + + // Act + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + response.Success.Should().BeFalse("Request to disabled model mapping should fail"); + response.StatusCode.Should().Be(404, "Disabled mapping should return 404"); + + _output.WriteLine($"Disabled mapping correctly handled: {response.StatusCode}"); + } + + // ===================================================== + // Circuit Breaker Behavior Tests + // ===================================================== + + [Fact(DisplayName = "Consecutive failures - Provider continues to accept requests")] + public async Task ConsecutiveFailures_StillAcceptsRequests() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + // Create provider with invalid key to simulate failures + var providerId = await SetupProviderAsync( + providerConfig, + overrideApiKey: "sk-invalid-key-that-will-fail-authentication-12345", + nameSuffix: "InvalidKey"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Act - Make multiple failing requests + var responses = new List>(); + + for (int i = 0; i < 5; i++) + { + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + responses.Add(response); + _output.WriteLine($"Request {i + 1}: Status={response.StatusCode}"); + + await Task.Delay(100); + } + + // Assert - All requests should be processed (not circuit breaker rejection) + // The key test is that each request is actually attempted, not fast-failed + var allProcessed = responses.All(r => + r.StatusCode == 401 || + r.StatusCode == 403 || + r.StatusCode == 500 || + r.StatusCode == 502); + + allProcessed.Should().BeTrue("All requests should be processed, not circuit-breaker rejected"); + + // Verify no spend was deducted + await AssertNoSpendDeductionAsync(groupId, balance); + + _output.WriteLine($"All {responses.Count} requests processed without circuit breaker blocking"); + } + + [Fact(DisplayName = "Recovery after failure - Successful request after fixing provider")] + public async Task RecoveryAfterFailure_SuccessfulRequest() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, balance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + // Create provider with VALID key + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Failover"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + await SetupModelCostAsync(providerConfig); + + // Act - Make a successful request + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Say 'recovered' in one word." } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + response.Success.Should().BeTrue($"Request should succeed: {response.Error}"); + response.Data.Should().NotBeNull(); + response.Data!.Choices.Should().NotBeEmpty(); + + _output.WriteLine($"Recovery successful: {response.Data.Choices[0].Message.Content}"); + + // Verify spend WAS deducted for successful request + await AssertSpendDeductedAsync(groupId, balance, 0.000001m); + _output.WriteLine("Successful request correctly billed"); + } + + // ===================================================== + // Timeout Simulation Tests + // ===================================================== + + [Fact(DisplayName = "Provider timeout - Returns gateway timeout error")] + public async Task ProviderTimeout_ReturnsGatewayTimeout() + { + // This test verifies timeout handling + // Since we can't easily cause a real timeout in integration tests, + // we document the expected behavior + + _output.WriteLine("Note: Full timeout testing requires mock infrastructure"); + _output.WriteLine("Expected behavior: 504 Gateway Timeout after configured timeout period"); + _output.WriteLine("Current configured timeouts:"); + _output.WriteLine($" - Default: {_config.Environment.Timeouts.Default}s"); + _output.WriteLine($" - Chat: {_config.Environment.Timeouts.Chat}s"); + _output.WriteLine($" - ImageGen: {_config.Environment.Timeouts.ImageGen}s"); + _output.WriteLine($" - VideoGen: {_config.Environment.Timeouts.VideoGen}s"); + + // Verify timeouts are configured + _config.Environment.Timeouts.Default.Should().BeGreaterThan(0); + _config.Environment.Timeouts.Chat.Should().BeGreaterThan(0); + } +} diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/RequestPipelineIntegrationTests.cs b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/RequestPipelineIntegrationTests.cs new file mode 100644 index 000000000..32585a5ea --- /dev/null +++ b/Tests/ConduitLLM.IntegrationTests/Tests/CriticalPath/RequestPipelineIntegrationTests.cs @@ -0,0 +1,403 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; +using ConduitLLM.IntegrationTests.Core; +using ConduitLLM.IntegrationTests.Infrastructure; + +namespace ConduitLLM.IntegrationTests.Tests.CriticalPath; + +/// +/// Integration tests for the Request Processing Pipeline critical path. +/// Tests end-to-end flows, provider routing, billing policy, and streaming. +/// +[Collection("Critical Path")] +[Trait("Category", "Integration")] +[Trait("CriticalPath", "true")] +public class RequestPipelineIntegrationTests : CriticalPathTestBase +{ + private readonly ITestOutputHelper _output; + private readonly ILogger _specificLogger; + + public RequestPipelineIntegrationTests( + TestFixture fixture, + RedisTestContainerFixture redisFixture, + ITestOutputHelper output) + : base(fixture, redisFixture) + { + _output = output; + _specificLogger = _fixture.ServiceProvider.GetRequiredService>(); + } + + protected override ILogger CreateLogger() + { + return _fixture.ServiceProvider.GetRequiredService>(); + } + + // ===================================================== + // End-to-End Flow Tests + // ===================================================== + + [Fact(DisplayName = "Chat Completion - Non-streaming returns complete response")] + public async Task ChatCompletion_NonStreaming_ReturnsCompleteResponse() + { + // Arrange + await WaitForServicesAsync(); + + // Skip if no active providers + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + // Set up minimal provider infrastructure + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Pipeline"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Act + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Say 'hello' in exactly one word." } + }, + Stream = false + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + response.Success.Should().BeTrue($"Chat completion should succeed: {response.Error}"); + response.Data.Should().NotBeNull(); + response.Data!.Choices.Should().NotBeEmpty("Response should have choices"); + response.Data.Choices[0].Message.Content.Should().NotBeNullOrEmpty("Response should have content"); + response.Data.Usage.Should().NotBeNull("Response should include usage data"); + response.Data.Usage!.TotalTokens.Should().BeGreaterThan(0, "Total tokens should be tracked"); + + _output.WriteLine($"Chat completion successful: {response.Data.Usage.TotalTokens} tokens"); + _output.WriteLine($"Response: {response.Data.Choices[0].Message.Content}"); + } + + [Fact(DisplayName = "Chat Completion - Streaming returns SSE events")] + public async Task ChatCompletion_Streaming_ReturnsSSEEvents() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Pipeline"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Act + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Count from 1 to 5." } + }, + Stream = true + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") + { + Content = new StringContent( + JsonSerializer.Serialize(chatRequest), + Encoding.UTF8, + "application/json") + }; + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", virtualKey); + + using var response = await _apiClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead); + response.IsSuccessStatusCode.Should().BeTrue($"Streaming request should succeed: {response.StatusCode}"); + + // Parse SSE events + using var stream = await response.Content.ReadAsStreamAsync(); + var events = await StreamingResponseParser.CollectAllAsync(stream); + + // Assert + events.Should().NotBeEmpty("Should receive SSE events"); + + var contentEvents = events.Where(e => !e.IsDone && e.EventType != "metrics-final").ToList(); + contentEvents.Should().NotBeEmpty("Should receive content events"); + + var content = StreamingResponseParser.ExtractContent(events); + content.Should().NotBeNullOrEmpty("Aggregated content should not be empty"); + + var usage = StreamingResponseParser.ExtractFinalUsage(events); + // Usage may or may not be present depending on provider + if (usage != null) + { + usage.TotalTokens.Should().BeGreaterThan(0); + _output.WriteLine($"Streaming usage: {usage.TotalTokens} tokens"); + } + + _output.WriteLine($"Received {events.Count} SSE events"); + _output.WriteLine($"Content: {content[..Math.Min(100, content.Length)]}..."); + } + + [Fact(DisplayName = "Chat Completion - Streaming extracts usage from final chunk")] + public async Task ChatCompletion_Streaming_ExtractsUsageFromFinalChunk() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Pipeline"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Act + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "What is 2+2?" } + }, + Stream = true + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") + { + Content = new StringContent( + JsonSerializer.Serialize(chatRequest), + Encoding.UTF8, + "application/json") + }; + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", virtualKey); + + using var response = await _apiClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead); + using var stream = await response.Content.ReadAsStreamAsync(); + var events = await StreamingResponseParser.CollectAllAsync(stream); + + // Assert + var doneEvent = events.FirstOrDefault(e => e.IsDone); + doneEvent.Should().NotBeNull("Stream should end with [DONE] marker"); + + // Check for metrics-final event (Conduit-specific) + var metricsFinal = events.FirstOrDefault(e => e.EventType == "metrics-final"); + if (metricsFinal != null) + { + _output.WriteLine($"Metrics final event: {metricsFinal.Data}"); + var usage = StreamingResponseParser.ExtractFinalUsage(events); + usage.Should().NotBeNull("Usage should be extractable from metrics-final"); + } + else + { + _output.WriteLine("No metrics-final event (provider may not support it)"); + } + } + + // ===================================================== + // Provider Routing Tests + // ===================================================== + + [Fact(DisplayName = "Model Routing - Valid alias routes to correct provider")] + public async Task ModelRouting_ValidAlias_RoutesToCorrectProvider() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Pipeline"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + + // Act + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + response.Success.Should().BeTrue($"Valid model alias should route successfully: {response.Error}"); + response.Data.Should().NotBeNull(); + + // The returned model should be the actual provider model + _output.WriteLine($"Requested model: {modelAlias}"); + _output.WriteLine($"Response model: {response.Data!.Model}"); + } + + [Fact(DisplayName = "Model Routing - Unknown model returns 404")] + public async Task ModelRouting_UnknownModel_Returns404() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Act + var chatRequest = new ChatCompletionRequest + { + Model = "nonexistent-model-that-does-not-exist-12345", + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + // Assert + response.Success.Should().BeFalse("Unknown model should fail"); + response.StatusCode.Should().Be(404, "Unknown model should return 404 Not Found"); + + _output.WriteLine("Unknown model correctly returned 404"); + } + + // ===================================================== + // Billing Policy Tests (CRITICAL: 4xx/5xx NOT billed) + // ===================================================== + + [Fact(DisplayName = "Billing Policy - 400 Bad Request does not deduct spend")] + public async Task ClientError_400BadRequest_DoesNotDeductSpend() + { + // Arrange + await WaitForServicesAsync(); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 10.00m); + + // Act - Send malformed request (missing messages) + var malformedRequest = new { model = "test-model" }; // Missing required 'messages' field + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + malformedRequest, + virtualKey); + + // Assert + response.Success.Should().BeFalse("Malformed request should fail"); + response.StatusCode.Should().BeOneOf(new[] { 400, 404 }, "Should return client error"); + + // Verify no spend was deducted + await AssertNoSpendDeductionAsync(groupId, initialBalance); + + _output.WriteLine($"400 error correctly NOT billed. Balance unchanged at ${initialBalance:F6}"); + } + + [Fact(DisplayName = "Billing Policy - 429 Rate Limited does not deduct spend")] + public async Task RateLimited_429_DoesNotDeductSpend() + { + // Arrange + await WaitForServicesAsync(); + + // Create key with very low rate limit + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync( + rpm: 1, + initialCredit: 10.00m); + + // Act - Make requests to trigger rate limiting + var response1 = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + var response2 = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + var response3 = await _apiClient.CoreGetAsync("/v1/models", virtualKey); + + // Find a rate-limited response + var rateLimitedResponses = new[] { response1, response2, response3 } + .Where(r => r.StatusCode == 429) + .ToList(); + + if (!rateLimitedResponses.Any()) + { + _output.WriteLine("Note: Rate limiting not triggered in this test run"); + return; + } + + // Assert + await AssertNoSpendDeductionAsync(groupId, initialBalance); + + _output.WriteLine($"429 rate limited requests correctly NOT billed"); + } + + [Fact(DisplayName = "Billing Policy - Successful request deducts spend")] + public async Task Success_200_DeductsSpend() + { + // Arrange + await WaitForServicesAsync(); + + if (!_config.ActiveProviders.Any()) + { + _output.WriteLine("Skipping: No active providers configured"); + return; + } + + var providerName = _config.ActiveProviders.First(); + var providerConfig = ConfigurationLoader.LoadProviderConfig(providerName); + var (virtualKey, groupId, initialBalance) = await CreateRateLimitedKeyAsync(initialCredit: 100.00m); + + var providerId = await SetupProviderAsync(providerConfig, nameSuffix: "Pipeline"); + var (modelAlias, _) = await SetupModelMappingAsync(providerId, providerConfig); + await SetupModelCostAsync(providerConfig); + + // Act - Make a successful chat request + var chatRequest = new ChatCompletionRequest + { + Model = modelAlias, + Messages = new List + { + new() { Role = "user", Content = "Hello" } + } + }; + + var response = await _apiClient.CorePostAsync( + "/v1/chat/completions", + chatRequest, + virtualKey); + + response.Success.Should().BeTrue($"Chat request should succeed: {response.Error}"); + + // Assert - Spend should be deducted + await AssertSpendDeductedAsync(groupId, initialBalance, 0.000001m); + + _output.WriteLine($"Successful request correctly billed"); + } +} diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/SambaNovaEndToEndTest.cs b/Tests/ConduitLLM.IntegrationTests/Tests/SambaNovaEndToEndTest.cs index bb23e6d6f..89ea5c6ab 100644 --- a/Tests/ConduitLLM.IntegrationTests/Tests/SambaNovaEndToEndTest.cs +++ b/Tests/ConduitLLM.IntegrationTests/Tests/SambaNovaEndToEndTest.cs @@ -57,7 +57,7 @@ public async Task SambaNovaProvider_BasicChat_ShouldWork() reportGenerated = true; // Now check if there were errors and fail the test if needed - if (_context.Errors.Count() > 0) + if (_context.Errors.Any()) { var errorMessage = string.Join("; ", _context.Errors); _specificLogger.LogError("Test completed with errors: {Errors}", errorMessage); diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithReasoningTest.cs b/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithReasoningTest.cs index 1850e0305..f6b40e180 100644 --- a/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithReasoningTest.cs +++ b/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithReasoningTest.cs @@ -105,7 +105,7 @@ public async Task StreamingWithReasoning_ShouldEmitReasoningEvents() reportGenerated = true; // Check if there were errors - if (_context.Errors.Count() > 0) + if (_context.Errors.Any()) { var errorMessage = string.Join("; ", _context.Errors); _specificLogger.LogError("Test completed with errors: {Errors}", errorMessage); diff --git a/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithToolCallsTest.cs b/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithToolCallsTest.cs index b52782af9..109ff87ad 100644 --- a/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithToolCallsTest.cs +++ b/Tests/ConduitLLM.IntegrationTests/Tests/StreamingWithToolCallsTest.cs @@ -109,7 +109,7 @@ public async Task StreamingWithToolCalls_ShouldEmitToolExecutingEvents() reportGenerated = true; // Check if there were errors - if (_context.Errors.Count() > 0) + if (_context.Errors.Any()) { var errorMessage = string.Join("; ", _context.Errors); _specificLogger.LogError("Test completed with errors: {Errors}", errorMessage); diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/AdminControllerBaseTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/AdminControllerBaseTests.cs new file mode 100644 index 000000000..54895d0e0 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/AdminControllerBaseTests.cs @@ -0,0 +1,497 @@ +using ConduitLLM.Admin.Controllers; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Tests.TestHelpers; + +using FluentAssertions; + +using MassTransit; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +using Moq; + +using Xunit.Abstractions; + +namespace ConduitLLM.Tests.Admin.Controllers +{ + /// + /// Unit tests for the AdminControllerBase class. + /// Tests exception handling and standardized error responses. + /// + [Trait("Category", "Unit")] + [Trait("Component", "AdminController")] + public class AdminControllerBaseTests + { + private readonly Mock _mockPublishEndpoint; + private readonly Mock> _mockLogger; + private readonly TestableAdminController _controller; + private readonly ITestOutputHelper _output; + + public AdminControllerBaseTests(ITestOutputHelper output) + { + _output = output; + _mockPublishEndpoint = new Mock(); + _mockLogger = new Mock>(); + _controller = new TestableAdminController(_mockPublishEndpoint.Object, _mockLogger.Object); + + // Setup controller context for testing + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + } + + #region ExecuteAsync Tests + + [Fact] + public async Task ExecuteAsync_OnSuccess_ReturnsSuccessResult() + { + // Arrange + var expectedResult = new TestDto { Id = 1, Name = "Test" }; + Func> operation = () => Task.FromResult(expectedResult); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + result.Should().BeOfType(); + var okResult = (OkObjectResult)result; + okResult.Value.Should().Be(expectedResult); + } + + [Fact] + public async Task ExecuteAsync_OnArgumentException_Returns400BadRequest() + { + // Arrange + var exceptionMessage = "Invalid argument provided"; + Func> operation = () => throw new ArgumentException(exceptionMessage); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + result.Should().BeOfType(); + var badRequest = (BadRequestObjectResult)result; + badRequest.Value.Should().BeOfType(); + var error = (ErrorResponseDto)badRequest.Value!; + error.error.Should().Be("Invalid parameter value"); + error.Code.Should().Be("invalid_parameter"); + } + + [Fact] + public async Task ExecuteAsync_OnArgumentNullException_Returns400BadRequest() + { + // Arrange + var paramName = "testParam"; + Func> operation = () => throw new ArgumentNullException(paramName); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + result.Should().BeOfType(); + var badRequest = (BadRequestObjectResult)result; + badRequest.Value.Should().BeOfType(); + var error = (ErrorResponseDto)badRequest.Value!; + error.Code.Should().Be("missing_parameter"); + } + + [Fact] + public async Task ExecuteAsync_OnInvalidOperationException_Returns400BadRequest() + { + // Arrange + var exceptionMessage = "Invalid operation attempted"; + Func> operation = () => throw new InvalidOperationException(exceptionMessage); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + result.Should().BeOfType(); + var badRequest = (BadRequestObjectResult)result; + badRequest.Value.Should().BeOfType(); + var error = (ErrorResponseDto)badRequest.Value!; + error.error.Should().Be("The requested operation is not valid"); + error.Code.Should().Be("invalid_operation"); + } + + [Fact] + public async Task ExecuteAsync_OnKeyNotFoundException_Returns404NotFound() + { + // Arrange + Func> operation = () => throw new KeyNotFoundException("Resource not found"); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + result.Should().BeOfType(); + var notFound = (NotFoundObjectResult)result; + notFound.Value.Should().BeOfType(); + var error = (ErrorResponseDto)notFound.Value!; + error.Code.Should().Be("not_found"); + } + + [Fact] + public async Task ExecuteAsync_OnUnauthorizedAccessException_Returns401Unauthorized() + { + // Arrange + Func> operation = () => throw new UnauthorizedAccessException(); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + result.Should().BeOfType(); + var objectResult = (ObjectResult)result; + objectResult.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + objectResult.Value.Should().BeOfType(); + var error = (ErrorResponseDto)objectResult.Value!; + error.Code.Should().Be("unauthorized"); + } + + [Fact] + public async Task ExecuteAsync_OnGenericException_Returns500InternalServerError() + { + // Arrange + Func> operation = () => throw new InvalidProgramException("Unexpected error"); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + result.Should().BeOfType(); + var objectResult = (ObjectResult)result; + objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + objectResult.Value.Should().BeOfType(); + var error = (ErrorResponseDto)objectResult.Value!; + error.Code.Should().Be("internal_error"); + } + + #endregion + + #region ExecuteAsync (void) Tests + + [Fact] + public async Task ExecuteAsync_VoidOperation_OnSuccess_ReturnsSuccessResult() + { + // Arrange + var executed = false; + Func operation = () => { executed = true; return Task.CompletedTask; }; + var successResult = new NoContentResult(); + + // Act + var result = await _controller.TestExecuteAsync(operation, successResult, "VoidOperation"); + + // Assert + executed.Should().BeTrue(); + result.Should().Be(successResult); + } + + [Fact] + public async Task ExecuteAsync_VoidOperation_OnException_ReturnsAppropriateError() + { + // Arrange + Func operation = () => throw new ArgumentException("Invalid"); + var successResult = new NoContentResult(); + + // Act + var result = await _controller.TestExecuteAsync(operation, successResult, "VoidOperation"); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region ExecuteWithNotFoundAsync Tests + + [Fact] + public async Task ExecuteWithNotFoundAsync_WhenEntityExists_ReturnsSuccessResult() + { + // Arrange + var expectedResult = new TestDto { Id = 1, Name = "Found Entity" }; + Func> operation = () => Task.FromResult(expectedResult); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteWithNotFoundAsync( + operation, successAction, "TestEntity", 1, "GetById"); + + // Assert + result.Should().BeOfType(); + var okResult = (OkObjectResult)result; + okResult.Value.Should().Be(expectedResult); + } + + [Fact] + public async Task ExecuteWithNotFoundAsync_WhenEntityNull_Returns404NotFound() + { + // Arrange + Func> operation = () => Task.FromResult(null); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteWithNotFoundAsync( + operation, successAction, "TestEntity", 42, "GetById"); + + // Assert + result.Should().BeOfType(); + var notFound = (NotFoundObjectResult)result; + notFound.Value.Should().BeOfType(); + var error = (ErrorResponseDto)notFound.Value!; + error.error.ToString().Should().Contain("TestEntity"); + error.error.ToString().Should().Contain("42"); + error.Code.Should().Be("not_found"); + } + + [Fact] + public async Task ExecuteWithNotFoundAsync_WhenEntityNull_LogsWarning() + { + // Arrange + Func> operation = () => Task.FromResult(null); + Func successAction = dto => new OkObjectResult(dto); + + // Act + await _controller.TestExecuteWithNotFoundAsync( + operation, successAction, "Provider", 123, "GetProviderById"); + + // Assert + _mockLogger.VerifyLog(LogLevel.Warning, "not found", Times.Once()); + } + + [Fact] + public async Task ExecuteWithNotFoundAsync_WithAsyncSuccessAction_ExecutesCorrectly() + { + // Arrange + var expectedResult = new TestDto { Id = 1, Name = "Entity" }; + Func> operation = () => Task.FromResult(expectedResult); + Func> asyncSuccessAction = async dto => + { + await Task.Delay(1); // Simulate async work + return new OkObjectResult(new { dto.Id, Processed = true }); + }; + + // Act + var result = await _controller.TestExecuteWithNotFoundAsyncAction( + operation, asyncSuccessAction, "TestEntity", 1, "GetAndProcess"); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task ExecuteWithNotFoundAsync_OnException_ReturnsAppropriateError() + { + // Arrange + Func> operation = () => throw new InvalidOperationException("Database error"); + Func successAction = dto => new OkObjectResult(dto); + + // Act + var result = await _controller.TestExecuteWithNotFoundAsync( + operation, successAction, "TestEntity", 1, "GetById"); + + // Assert + result.Should().BeOfType(); + var badRequest = (BadRequestObjectResult)result; + var error = (ErrorResponseDto)badRequest.Value!; + error.error.Should().Be("The requested operation is not valid"); + error.Code.Should().Be("invalid_operation"); + } + + #endregion + + #region Exception Logging Tests + + [Fact] + public async Task ExecuteAsync_OnArgumentException_LogsWarning() + { + // Arrange + Func> operation = () => throw new ArgumentException("Bad arg"); + Func successAction = dto => new OkObjectResult(dto); + + // Act + await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + _mockLogger.VerifyLog(LogLevel.Warning, "Argument error", Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_OnInvalidOperationException_LogsWarning() + { + // Arrange + Func> operation = () => throw new InvalidOperationException("Invalid op"); + Func successAction = dto => new OkObjectResult(dto); + + // Act + await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + _mockLogger.VerifyLog(LogLevel.Warning, "Invalid operation", Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_OnKeyNotFoundException_LogsWarning() + { + // Arrange + Func> operation = () => throw new KeyNotFoundException(); + Func successAction = dto => new OkObjectResult(dto); + + // Act + await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + _mockLogger.VerifyLog(LogLevel.Warning, "not found", Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_OnUnauthorizedAccessException_LogsWarning() + { + // Arrange + Func> operation = () => throw new UnauthorizedAccessException(); + Func successAction = dto => new OkObjectResult(dto); + + // Act + await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + _mockLogger.VerifyLog(LogLevel.Warning, "Unauthorized", Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_OnGenericException_LogsError() + { + // Arrange + Func> operation = () => throw new Exception("Unexpected"); + Func successAction = dto => new OkObjectResult(dto); + + // Act + await _controller.TestExecuteAsync(operation, successAction, "TestOperation"); + + // Assert + _mockLogger.VerifyLog(LogLevel.Error, "Unexpected error", Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_WithContextData_IncludesContextInLog() + { + // Arrange + Func> operation = () => throw new ArgumentException("Error"); + Func successAction = dto => new OkObjectResult(dto); + var contextData = new { EntityId = 42, EntityType = "Provider" }; + + // Act + await _controller.TestExecuteAsync(operation, successAction, "GetProvider", contextData); + + // Assert + _mockLogger.VerifyLog(LogLevel.Warning, "GetProvider", Times.Once()); + } + + #endregion + + #region Constructor Tests + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + var act = () => new TestableAdminController(_mockPublishEndpoint.Object, null!); + act.Should().Throw().WithParameterName("logger"); + } + + [Fact] + public void Constructor_WithNullPublishEndpoint_DoesNotThrow() + { + // Act & Assert + var act = () => new TestableAdminController(null, _mockLogger.Object); + act.Should().NotThrow(); + } + + #endregion + } + + #region Test Helpers + + /// + /// Test DTO for verifying operation results. + /// + public class TestDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + /// + /// Concrete implementation of AdminControllerBase for testing. + /// Exposes protected methods as public for testing purposes. + /// + public class TestableAdminController : AdminControllerBase + { + public TestableAdminController(IPublishEndpoint? publishEndpoint, ILogger logger) + : base(publishEndpoint, logger) + { + } + + /// + /// Exposes ExecuteAsync for testing. + /// + public Task TestExecuteAsync( + Func> operation, + Func successAction, + string operationName, + object? contextData = null) + { + return ExecuteAsync(operation, successAction, operationName, contextData); + } + + /// + /// Exposes ExecuteAsync (void) for testing. + /// + public Task TestExecuteAsync( + Func operation, + IActionResult successResult, + string operationName, + object? contextData = null) + { + return ExecuteAsync(operation, successResult, operationName, contextData); + } + + /// + /// Exposes ExecuteWithNotFoundAsync for testing (sync success action). + /// + public Task TestExecuteWithNotFoundAsync( + Func> operation, + Func successAction, + string entityType, + object? entityId, + string operationName) where T : class + { + return ExecuteWithNotFoundAsync(operation, successAction, entityType, entityId, operationName); + } + + /// + /// Exposes ExecuteWithNotFoundAsync for testing (async success action). + /// + public Task TestExecuteWithNotFoundAsyncAction( + Func> operation, + Func> successAction, + string entityType, + object? entityId, + string operationName) where T : class + { + return ExecuteWithNotFoundAsync(operation, successAction, entityType, entityId, operationName); + } + } + + #endregion +} diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/AnalyticsControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/AnalyticsControllerTests.cs new file mode 100644 index 000000000..6e3061133 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/AnalyticsControllerTests.cs @@ -0,0 +1,209 @@ +using ConduitLLM.Admin.Controllers; +using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.DTOs.Costs; + +using FluentAssertions; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +using Moq; + +using Xunit.Abstractions; + +namespace ConduitLLM.Tests.Admin.Controllers +{ + [Trait("Category", "Unit")] + [Trait("Component", "AdminController")] + public class AnalyticsControllerTests + { + private readonly Mock _mockAnalyticsService; + private readonly Mock _mockAnalyticsMetrics; + private readonly Mock> _mockLogger; + private readonly AnalyticsController _controller; + private readonly ITestOutputHelper _output; + + public AnalyticsControllerTests(ITestOutputHelper output) + { + _output = output; + _mockAnalyticsService = new Mock(); + _mockAnalyticsMetrics = new Mock(); + _mockLogger = new Mock>(); + _controller = new AnalyticsController( + _mockAnalyticsService.Object, + _mockLogger.Object, + _mockAnalyticsMetrics.Object); + } + + #region GetLogs Tests + + [Fact] + public async Task GetLogs_ValidParams_ReturnsOk() + { + // Arrange + var pagedResult = new PagedResult + { + Items = new List(), + TotalCount = 0, + Page = 1, + PageSize = 50 + }; + _mockAnalyticsService.Setup(s => s.GetLogsAsync(1, 50, null, null, null, null, null)) + .ReturnsAsync(pagedResult); + + // Act + var result = await _controller.GetLogs(); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task GetLogs_InvalidPage_ReturnsBadRequest() + { + // Act + var result = await _controller.GetLogs(page: 0); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task GetLogs_PageSizeTooLarge_ReturnsBadRequest() + { + // Act + var result = await _controller.GetLogs(pageSize: 101); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region ExportAnalytics Tests + + [Fact] + public async Task ExportAnalytics_ValidCsvExport_ReturnsFileAndLogsAudit() + { + // Arrange + var csvData = System.Text.Encoding.UTF8.GetBytes("header1,header2\nval1,val2"); + _mockAnalyticsService.Setup(s => s.ExportAnalyticsAsync("csv", null, null, null, null)) + .ReturnsAsync(csvData); + + // Act + var result = await _controller.ExportAnalytics(format: "csv"); + + // Assert + result.Should().BeOfType(); + var fileResult = (FileContentResult)result; + fileResult.ContentType.Should().Be("text/csv"); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Admin Audit") && o.ToString()!.Contains("Exported")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ExportAnalytics_InvalidFormat_ReturnsBadRequest() + { + // Act + var result = await _controller.ExportAnalytics(format: "xml"); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region InvalidateCache Tests + + [Fact] + public async Task InvalidateCache_LogsAudit() + { + // Act + var result = await _controller.InvalidateCache("test reason"); + + // Assert + result.Should().BeOfType(); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Admin Audit") && o.ToString()!.Contains("Invalidated")), + null, + It.IsAny>()), + Times.Once); + } + + #endregion + + #region GetCostSummary Tests + + [Fact] + public async Task GetCostSummary_InvalidTimeframe_ReturnsBadRequest() + { + // Act + var result = await _controller.GetCostSummary(timeframe: "yearly"); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task GetCostSummary_ValidTimeframe_ReturnsOk() + { + // Arrange + _mockAnalyticsService.Setup(s => s.GetCostSummaryAsync("daily", null, null)) + .ReturnsAsync(new CostDashboardDto()); + + // Act + var result = await _controller.GetCostSummary(timeframe: "daily"); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region GetCacheMetrics Tests + + [Fact] + public void GetCacheMetrics_MetricsEnabled_ReturnsOk() + { + // Arrange + _mockAnalyticsMetrics.Setup(m => m.GetCacheStatistics()) + .Returns(new Dictionary { ["hitRate"] = 0.95 }); + + // Act + var result = _controller.GetCacheMetrics(); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public void GetCacheMetrics_MetricsDisabled_ReturnsNotFound() + { + // Arrange - controller with null metrics + var controller = new AnalyticsController( + _mockAnalyticsService.Object, + _mockLogger.Object, + analyticsMetrics: null); + + // Act + var result = controller.GetCacheMetrics(); + + // Assert + result.Should().BeOfType(); + } + + #endregion + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ConfigurationControllerTests.LLMCache.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ConfigurationControllerTests.LLMCache.cs index 032639329..904c6b9e7 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ConfigurationControllerTests.LLMCache.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ConfigurationControllerTests.LLMCache.cs @@ -5,6 +5,7 @@ using ConduitLLM.Admin.Controllers; using ConduitLLM.Admin.Services; using ConduitLLM.Configuration; +using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.DTOs.Cache; using FluentAssertions; using MassTransit; @@ -103,17 +104,13 @@ public async Task GetLLMCacheStatus_ServiceThrowsException_Returns500() // Act var result = await _controller.GetLLMCacheStatus(); - // Assert + // Assert - AdminControllerBase returns ObjectResult with ErrorResponseDto var statusResult = result.Should().BeOfType().Subject; statusResult.StatusCode.Should().Be(500); - var responseValue = statusResult.Value; - responseValue.Should().NotBeNull(); - var valueType = responseValue!.GetType(); - var errorProperty = valueType.GetProperty("error"); - errorProperty.Should().NotBeNull(); - var errorValue = errorProperty?.GetValue(responseValue) as string; - errorValue.Should().Be("Failed to get LLM cache status"); + var errorResponse = statusResult.Value.Should().BeOfType().Subject; + errorResponse.error.Should().Be("An unexpected error occurred"); + errorResponse.Code.Should().Be("internal_error"); } [Fact] @@ -313,17 +310,13 @@ public async Task ToggleLLMCache_ServiceThrowsException_Returns500() // Act var result = await _controller.ToggleLLMCache(request); - // Assert + // Assert - AdminControllerBase returns ObjectResult with ErrorResponseDto var statusResult = result.Should().BeOfType().Subject; statusResult.StatusCode.Should().Be(500); - var responseValue = statusResult.Value; - responseValue.Should().NotBeNull(); - var valueType = responseValue!.GetType(); - var errorProperty = valueType.GetProperty("error"); - errorProperty.Should().NotBeNull(); - var errorValue = errorProperty?.GetValue(responseValue) as string; - errorValue.Should().Be("Failed to toggle LLM cache"); + var errorResponse = statusResult.Value.Should().BeOfType().Subject; + errorResponse.error.Should().Be("An unexpected error occurred"); + errorResponse.Code.Should().Be("internal_error"); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Create.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Create.cs index 36ad7b44b..b99fef5e5 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Create.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Create.cs @@ -1,9 +1,7 @@ using ConduitLLM.Admin.Controllers; -using ConduitLLM.Tests.Admin.TestHelpers; using ConduitLLM.Configuration.DTOs; using FluentAssertions; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; namespace ConduitLLM.Tests.Admin.Controllers @@ -38,11 +36,11 @@ public async Task CreateSetting_WithValidData_ShouldReturnCreated() var result = await _controller.CreateSetting(createDto); // Assert - var createdResult = Assert.IsType(result); + var createdResult = result.Should().BeOfType().Subject; createdResult.ActionName.Should().Be(nameof(GlobalSettingsController.GetSettingById)); createdResult.RouteValues!["id"].Should().Be(10); - - var returnedSetting = Assert.IsType(createdResult.Value); + + var returnedSetting = createdResult.Value.Should().BeOfType().Subject; returnedSetting.Key.Should().Be("new_setting"); } @@ -63,10 +61,10 @@ public async Task CreateSetting_WithDuplicateKey_ShouldReturnBadRequest() var result = await _controller.CreateSetting(createDto); // Assert - var badRequestResult = Assert.IsType(result); - badRequestResult.Value.Should().Be("Setting with key already exists"); - - _mockLogger.VerifyLogWithAnyException(LogLevel.Warning, "Invalid operation when creating global setting"); + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + errorResponse.error.Should().Be("The requested operation is not valid"); + errorResponse.Code.Should().Be("invalid_operation"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Delete.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Delete.cs index 3c2b820d3..ef510d2b7 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Delete.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Delete.cs @@ -1,10 +1,9 @@ -using ConduitLLM.Tests.Admin.TestHelpers; +using ConduitLLM.Configuration.DTOs; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; @@ -25,7 +24,7 @@ public async Task DeleteSetting_WithExistingId_ShouldReturnNoContent() var result = await _controller.DeleteSetting(1); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -39,9 +38,9 @@ public async Task DeleteSetting_WithNonExistingId_ShouldReturnNotFound() var result = await _controller.DeleteSetting(999); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Global setting not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion @@ -59,7 +58,7 @@ public async Task DeleteSettingByKey_WithExistingKey_ShouldReturnNoContent() var result = await _controller.DeleteSettingByKey("rate_limit"); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -73,9 +72,9 @@ public async Task DeleteSettingByKey_WithNonExistingKey_ShouldReturnNotFound() var result = await _controller.DeleteSettingByKey("non_existing"); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Global setting not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } [Fact] @@ -89,10 +88,10 @@ public async Task DeleteSettingByKey_WithException_ShouldReturn500() var result = await _controller.DeleteSettingByKey("test_key"); // Assert - var statusCodeResult = Assert.IsType(result); + var statusCodeResult = result.Should().BeOfType().Subject; statusCodeResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - - _mockLogger.VerifyLogWithAnyException(LogLevel.Error, "Error deleting global setting with key"); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetAllSettings.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetAllSettings.cs index be4504651..abf7dc10d 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetAllSettings.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetAllSettings.cs @@ -1,9 +1,7 @@ -using ConduitLLM.Tests.Admin.TestHelpers; using ConduitLLM.Configuration.DTOs; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; namespace ConduitLLM.Tests.Admin.Controllers @@ -30,8 +28,8 @@ public async Task GetAllSettings_WithSettings_ShouldReturnOkWithList() var result = await _controller.GetAllSettings(); // Assert - var okResult = Assert.IsType(result); - var returnedSettings = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedSettings = okResult.Value.Should().BeAssignableTo>().Subject; returnedSettings.Should().HaveCount(3); returnedSettings.First().Key.Should().Be("rate_limit"); } @@ -47,8 +45,8 @@ public async Task GetAllSettings_WithEmptyList_ShouldReturnOkWithEmptyList() var result = await _controller.GetAllSettings(); // Assert - var okResult = Assert.IsType(result); - var returnedSettings = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedSettings = okResult.Value.Should().BeAssignableTo>().Subject; returnedSettings.Should().BeEmpty(); } @@ -63,11 +61,10 @@ public async Task GetAllSettings_WithException_ShouldReturn500() var result = await _controller.GetAllSettings(); // Assert - var statusCodeResult = Assert.IsType(result); + var statusCodeResult = result.Should().BeOfType().Subject; statusCodeResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - statusCodeResult.Value.Should().Be("An unexpected error occurred."); - - _mockLogger.VerifyLogWithAnyException(LogLevel.Error, "Error getting all global settings"); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetById.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetById.cs index 8fb2079b4..6b38bca5a 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetById.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetById.cs @@ -31,8 +31,8 @@ public async Task GetSettingById_WithExistingId_ShouldReturnOkWithSetting() var result = await _controller.GetSettingById(1); // Assert - var okResult = Assert.IsType(result); - var returnedSetting = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedSetting = okResult.Value.Should().BeOfType().Subject; returnedSetting.Id.Should().Be(1); returnedSetting.Key.Should().Be("rate_limit"); returnedSetting.Value.Should().Be("1000"); @@ -49,9 +49,9 @@ public async Task GetSettingById_WithNonExistingId_ShouldReturnNotFound() var result = await _controller.GetSettingById(999); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Global setting not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetByKey.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetByKey.cs index 0f7c673ee..311685822 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetByKey.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.GetByKey.cs @@ -1,9 +1,7 @@ -using ConduitLLM.Tests.Admin.TestHelpers; using ConduitLLM.Configuration.DTOs; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; namespace ConduitLLM.Tests.Admin.Controllers @@ -31,8 +29,8 @@ public async Task GetSettingByKey_WithExistingKey_ShouldReturnOkWithSetting() var result = await _controller.GetSettingByKey("rate_limit"); // Assert - var okResult = Assert.IsType(result); - var returnedSetting = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedSetting = okResult.Value.Should().BeOfType().Subject; returnedSetting.Key.Should().Be("rate_limit"); } @@ -47,10 +45,10 @@ public async Task GetSettingByKey_WithNonExistingKey_ShouldReturnNotFound() var result = await _controller.GetSettingByKey("non_existing"); // Assert - var notFoundResult = Assert.IsType(result); + var notFoundResult = result.Should().BeOfType().Subject; notFoundResult.Value.Should().NotBeNull(); - var errorResponse = Assert.IsType(notFoundResult.Value); - errorResponse.error.Should().Be("Global setting not found"); + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } [Fact] @@ -64,10 +62,10 @@ public async Task GetSettingByKey_WithException_ShouldReturn500() var result = await _controller.GetSettingByKey("test_key"); // Assert - var statusCodeResult = Assert.IsType(result); + var statusCodeResult = result.Should().BeOfType().Subject; statusCodeResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - - _mockLogger.VerifyLogWithAnyException(LogLevel.Error, "Error getting global setting with key"); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Update.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Update.cs index 351f628e0..f1e1fd655 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Update.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/GlobalSettingsControllerTests.Update.cs @@ -1,9 +1,7 @@ -using ConduitLLM.Tests.Admin.TestHelpers; using ConduitLLM.Configuration.DTOs; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; namespace ConduitLLM.Tests.Admin.Controllers @@ -23,6 +21,8 @@ public async Task UpdateSetting_WithValidData_ShouldReturnNoContent() Description = "Updated description" }; + _mockService.Setup(x => x.GetSettingByIdAsync(1)) + .ReturnsAsync(new GlobalSettingDto { Id = 1, Key = "test_key", Value = "old_value", Description = "Old description" }); _mockService.Setup(x => x.UpdateSettingAsync(It.IsAny())) .ReturnsAsync(true); @@ -30,7 +30,7 @@ public async Task UpdateSetting_WithValidData_ShouldReturnNoContent() var result = await _controller.UpdateSetting(1, updateDto); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -47,7 +47,7 @@ public async Task UpdateSetting_WithMismatchedIds_ShouldReturnBadRequest() var result = await _controller.UpdateSetting(1, updateDto); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("ID in route must match ID in body"); } @@ -61,16 +61,16 @@ public async Task UpdateSetting_WithNonExistingId_ShouldReturnNotFound() Value = "value" }; - _mockService.Setup(x => x.UpdateSettingAsync(It.IsAny())) - .ReturnsAsync(false); + _mockService.Setup(x => x.GetSettingByIdAsync(999)) + .ReturnsAsync((GlobalSettingDto?)null); // Act var result = await _controller.UpdateSetting(999, updateDto); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Global setting not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion @@ -95,11 +95,11 @@ public async Task UpdateSettingByKey_WithValidData_ShouldReturnNoContent() var result = await _controller.UpdateSettingByKey(updateDto); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] - public async Task UpdateSettingByKey_WithFailure_ShouldReturn500() + public async Task UpdateSettingByKey_WithFailure_ShouldReturnBadRequest() { // Arrange var updateDto = new UpdateGlobalSettingByKeyDto @@ -115,9 +115,12 @@ public async Task UpdateSettingByKey_WithFailure_ShouldReturn500() var result = await _controller.UpdateSettingByKey(updateDto); // Assert - var statusCodeResult = Assert.IsType(result); - statusCodeResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - statusCodeResult.Value.Should().Be("Failed to update or create global setting"); + // Controller throws InvalidOperationException when service returns false, + // which AdminControllerBase maps to 400 Bad Request + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + errorResponse.error.Should().Be("The requested operation is not valid"); + errorResponse.Code.Should().Be("invalid_operation"); } [Fact] @@ -137,10 +140,10 @@ public async Task UpdateSettingByKey_WithException_ShouldReturn500() var result = await _controller.UpdateSettingByKey(updateDto); // Assert - var statusCodeResult = Assert.IsType(result); + var statusCodeResult = result.Should().BeOfType().Subject; statusCodeResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - - _mockLogger.VerifyLogWithAnyException(LogLevel.Error, "Error updating global setting with key"); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerIntegrationTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerIntegrationTests.cs index dbc0f2f82..a01cc734a 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerIntegrationTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerIntegrationTests.cs @@ -6,6 +6,7 @@ using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Repositories; using ConduitLLM.Core.Events; +using FluentAssertions; using MassTransit; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -71,7 +72,7 @@ public async Task UpdateModel_WithParameterChange_PublishesEventWithParametersCh _mockModelRepository.Setup(r => r.GetByIdWithDetailsAsync(modelId)) .ReturnsAsync(existingModel); - _mockModelRepository.Setup(r => r.UpdateAsync(It.IsAny())) + _mockModelRepository.Setup(r => r.UpdateModelAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(updatedModel); ModelUpdated? capturedEvent = null; @@ -83,11 +84,11 @@ public async Task UpdateModel_WithParameterChange_PublishesEventWithParametersCh var result = await _controller.UpdateModel(modelId, updateDto); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify the event was published _mockPublishEndpoint.Verify(p => p.Publish(It.IsAny(), default), Times.Once); - + // Verify the event has correct properties Assert.NotNull(capturedEvent); Assert.Equal(modelId, capturedEvent.ModelId); @@ -128,7 +129,7 @@ public async Task UpdateModel_WithoutParameterChange_PublishesEventWithParameter _mockModelRepository.Setup(r => r.GetByIdWithDetailsAsync(modelId)) .ReturnsAsync(existingModel); - _mockModelRepository.Setup(r => r.UpdateAsync(It.IsAny())) + _mockModelRepository.Setup(r => r.UpdateModelAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(updatedModel); ModelUpdated? capturedEvent = null; @@ -140,11 +141,11 @@ public async Task UpdateModel_WithoutParameterChange_PublishesEventWithParameter var result = await _controller.UpdateModel(modelId, updateDto); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify the event was published _mockPublishEndpoint.Verify(p => p.Publish(It.IsAny(), default), Times.Once); - + // Verify the event has correct properties Assert.NotNull(capturedEvent); Assert.Equal(modelId, capturedEvent.ModelId); diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.CrudOperations.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.CrudOperations.cs index 93e6501f6..bdb1dfafc 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.CrudOperations.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.CrudOperations.cs @@ -1,6 +1,7 @@ using ConduitLLM.Admin.Controllers; using ConduitLLM.Admin.Interfaces; using ConduitLLM.Admin.Models.Models; +using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Repositories; @@ -70,8 +71,8 @@ public async Task CreateModel_WithValidData_ShouldReturnCreatedWithModelDto() _mockRepository.Setup(r => r.GetByNameAsync(createDto.Name)) .ReturnsAsync((Model?)null); - _mockRepository.Setup(r => r.CreateAsync(It.IsAny())) - .ReturnsAsync((Model m) => { + _mockRepository.Setup(r => r.CreateModelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Model m, CancellationToken _) => { m.Id = 1; // Simulate the database setting the ID return m; }); @@ -82,17 +83,17 @@ public async Task CreateModel_WithValidData_ShouldReturnCreatedWithModelDto() var result = await _controller.CreateModel(createDto); // Assert - var createdResult = Assert.IsType(result); + var createdResult = result.Should().BeOfType().Subject; createdResult.StatusCode.Should().Be(StatusCodes.Status201Created); createdResult.ActionName.Should().Be(nameof(ModelController.GetModelById)); createdResult.RouteValues!["id"].Should().Be(1); - var dto = Assert.IsType(createdResult.Value); + var dto = createdResult.Value.Should().BeOfType().Subject; dto.Id.Should().Be(1); dto.Name.Should().Be("new-test-model"); dto.IsActive.Should().BeTrue(); - _mockRepository.Verify(r => r.CreateAsync(It.Is(m => + _mockRepository.Verify(r => r.CreateModelAsync(It.Is(m => m.Name == createDto.Name && m.ModelSeriesId == createDto.ModelSeriesId && m.IsActive == createDto.IsActive)), Times.Once); @@ -128,8 +129,8 @@ public async Task CreateModel_WithModelParameters_ShouldReturnCreatedWithParamet _mockRepository.Setup(r => r.GetByNameAsync(createDto.Name)) .ReturnsAsync((Model?)null); - _mockRepository.Setup(r => r.CreateAsync(It.IsAny())) - .ReturnsAsync((Model m) => { + _mockRepository.Setup(r => r.CreateModelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Model m, CancellationToken _) => { m.Id = 1; return m; }); @@ -140,11 +141,11 @@ public async Task CreateModel_WithModelParameters_ShouldReturnCreatedWithParamet var result = await _controller.CreateModel(createDto); // Assert - var createdResult = Assert.IsType(result); - var dto = Assert.IsType(createdResult.Value); + var createdResult = result.Should().BeOfType().Subject; + var dto = createdResult.Value.Should().BeOfType().Subject; dto.ModelParameters.Should().Be("{\"temperature\": {\"min\": 0, \"max\": 1.5}}"); - _mockRepository.Verify(r => r.CreateAsync(It.Is(m => + _mockRepository.Verify(r => r.CreateModelAsync(It.Is(m => m.ModelParameters == createDto.ModelParameters)), Times.Once); } @@ -158,10 +159,10 @@ public async Task CreateModel_WithNullData_ShouldReturnBadRequest() var result = await _controller.CreateModel(createDto); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("Model data is required"); - _mockRepository.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + _mockRepository.Verify(r => r.CreateModelAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -180,10 +181,10 @@ public async Task CreateModel_WithEmptyName_ShouldReturnBadRequest() var result = await _controller.CreateModel(createDto); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("Model name is required"); - _mockRepository.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + _mockRepository.Verify(r => r.CreateModelAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -211,10 +212,10 @@ public async Task CreateModel_WithDuplicateName_ShouldReturnConflict() var result = await _controller.CreateModel(createDto); // Assert - var conflictResult = Assert.IsType(result); + var conflictResult = result.Should().BeOfType().Subject; conflictResult.Value.Should().Be("A model with name 'existing-model' already exists"); - _mockRepository.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + _mockRepository.Verify(r => r.CreateModelAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -225,30 +226,22 @@ public async Task CreateModel_WhenRepositoryThrows_ShouldReturn500() { Name = "test-model", ModelSeriesId = 1, - + IsActive = true }; var exception = new Exception("Database connection failed"); - _mockRepository.Setup(r => r.CreateAsync(It.IsAny())) + _mockRepository.Setup(r => r.CreateModelAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(exception); // Act var result = await _controller.CreateModel(createDto); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while creating the model"); - - _mockLogger.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error creating model")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion @@ -293,21 +286,21 @@ public async Task UpdateModel_WithValidData_ShouldReturnOkWithUpdatedModel() _mockRepository.Setup(r => r.GetByIdWithDetailsAsync(modelId)) .ReturnsAsync(existingModel); - _mockRepository.Setup(r => r.UpdateAsync(It.IsAny())) + _mockRepository.Setup(r => r.UpdateModelAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(updatedModel); // Act var result = await _controller.UpdateModel(modelId, updateDto); // Assert - var okResult = Assert.IsType(result); - var dto = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var dto = okResult.Value.Should().BeOfType().Subject; dto.Id.Should().Be(modelId); dto.Name.Should().Be("updated-model-name"); dto.IsActive.Should().BeFalse(); _mockRepository.Verify(r => r.GetByIdWithDetailsAsync(modelId), Times.Once); - _mockRepository.Verify(r => r.UpdateAsync(It.Is(m => + _mockRepository.Verify(r => r.UpdateModelAsync(It.Is(m => m.Id == modelId && m.Name == updateDto.Name && m.IsActive == updateDto.IsActive)), Times.Once); @@ -331,11 +324,11 @@ public async Task UpdateModel_WithNonExistentId_ShouldReturnNotFound() var result = await _controller.UpdateModel(modelId, updateDto); // Assert - var notFoundResult = Assert.IsType(result); + var notFoundResult = result.Should().BeOfType().Subject; notFoundResult.Value.Should().Be($"Model with ID {modelId} not found"); _mockRepository.Verify(r => r.GetByIdWithDetailsAsync(modelId), Times.Once); - _mockRepository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + _mockRepository.Verify(r => r.UpdateModelAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -377,18 +370,18 @@ public async Task UpdateModel_WithModelParameters_ShouldUpdateParameters() _mockRepository.Setup(r => r.GetByIdWithDetailsAsync(modelId)) .ReturnsAsync(existingModel); - _mockRepository.Setup(r => r.UpdateAsync(It.IsAny())) + _mockRepository.Setup(r => r.UpdateModelAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(updatedModel); // Act var result = await _controller.UpdateModel(modelId, updateDto); // Assert - var okResult = Assert.IsType(result); - var dto = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var dto = okResult.Value.Should().BeOfType().Subject; dto.ModelParameters.Should().Be("{\"temperature\": {\"min\": 0, \"max\": 2}}"); - _mockRepository.Verify(r => r.UpdateAsync(It.Is(m => + _mockRepository.Verify(r => r.UpdateModelAsync(It.Is(m => m.ModelParameters == updateDto.ModelParameters)), Times.Once); } @@ -431,18 +424,18 @@ public async Task UpdateModel_WithEmptyModelParameters_ShouldClearParameters() _mockRepository.Setup(r => r.GetByIdWithDetailsAsync(modelId)) .ReturnsAsync(existingModel); - _mockRepository.Setup(r => r.UpdateAsync(It.IsAny())) + _mockRepository.Setup(r => r.UpdateModelAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(updatedModel); // Act var result = await _controller.UpdateModel(modelId, updateDto); // Assert - var okResult = Assert.IsType(result); - var dto = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var dto = okResult.Value.Should().BeOfType().Subject; dto.ModelParameters.Should().BeNull(); - _mockRepository.Verify(r => r.UpdateAsync(It.Is(m => + _mockRepository.Verify(r => r.UpdateModelAsync(It.Is(m => m.ModelParameters == null)), Times.Once); } @@ -457,11 +450,11 @@ public async Task UpdateModel_WithNullData_ShouldReturnBadRequest() var result = await _controller.UpdateModel(modelId, updateDto); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("Update data is required"); _mockRepository.Verify(r => r.GetByIdWithDetailsAsync(It.IsAny()), Times.Never); - _mockRepository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + _mockRepository.Verify(r => r.UpdateModelAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -483,11 +476,12 @@ public async Task UpdateModel_WhenGetByIdFails_ShouldReturn500() var result = await _controller.UpdateModel(modelId, updateDto); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while updating the model"); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); - _mockRepository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + _mockRepository.Verify(r => r.UpdateModelAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -509,18 +503,10 @@ public async Task UpdateModel_WhenRepositoryThrows_ShouldReturn500() var result = await _controller.UpdateModel(modelId, updateDto); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while updating the model"); - - _mockLogger.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error updating model with ID")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion @@ -574,7 +560,7 @@ public async Task DeleteModel_WithNonExistentId_ShouldReturnNotFound() var result = await _controller.DeleteModel(modelId); // Assert - var notFoundResult = Assert.IsType(result); + var notFoundResult = result.Should().BeOfType().Subject; notFoundResult.Value.Should().Be($"Model with ID {modelId} not found"); _mockRepository.Verify(r => r.GetByIdAsync(modelId), Times.Once); @@ -594,18 +580,10 @@ public async Task DeleteModel_WhenRepositoryThrows_ShouldReturn500() var result = await _controller.DeleteModel(modelId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while deleting the model"); - - _mockLogger.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error deleting model with ID")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.GetOperations.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.GetOperations.cs index 96966128f..e3c3c3dd9 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.GetOperations.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.GetOperations.cs @@ -2,6 +2,7 @@ using ConduitLLM.Admin.Interfaces; using ConduitLLM.Admin.Models.Models; using ConduitLLM.Configuration; +using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Repositories; @@ -74,45 +75,42 @@ public async Task GetAllModels_WithModels_ShouldReturnOkWithModelDtos() } }; - _mockRepository.Setup(r => r.GetAllWithDetailsAsync()) - .ReturnsAsync(models); + _mockRepository.Setup(r => r.GetPaginatedWithFilterAsync(null, null, null, null, null)) + .ReturnsAsync((models, models.Count)); - // Act + // Act โ€” no pagination params returns flat array var result = await _controller.GetAllModels(); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var dtos = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var dtos = okResult.Value.Should().BeAssignableTo>().Subject; dtos.Should().HaveCount(2); var firstDto = dtos.First(); firstDto.Id.Should().Be(1); firstDto.Name.Should().Be("test-model-1"); firstDto.IsActive.Should().BeTrue(); - // Capabilities are now flat fields on the model - just verify they exist by checking the Id firstDto.Id.Should().BePositive(); - _mockRepository.Verify(r => r.GetAllWithDetailsAsync(), Times.Once); + _mockRepository.Verify(r => r.GetPaginatedWithFilterAsync(null, null, null, null, null), Times.Once); } [Fact] public async Task GetAllModels_WithEmptyList_ShouldReturnOkWithEmptyList() { // Arrange - _mockRepository.Setup(r => r.GetAllWithDetailsAsync()) - .ReturnsAsync(new List()); + _mockRepository.Setup(r => r.GetPaginatedWithFilterAsync(null, null, null, null, null)) + .ReturnsAsync((new List(), 0)); // Act var result = await _controller.GetAllModels(); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var dtos = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var dtos = okResult.Value.Should().BeAssignableTo>().Subject; dtos.Should().BeEmpty(); - _mockRepository.Verify(r => r.GetAllWithDetailsAsync(), Times.Once); + _mockRepository.Verify(r => r.GetPaginatedWithFilterAsync(null, null, null, null, null), Times.Once); } [Fact] @@ -120,26 +118,17 @@ public async Task GetAllModels_WhenRepositoryThrows_ShouldReturn500() { // Arrange var exception = new Exception("Database connection failed"); - _mockRepository.Setup(r => r.GetAllWithDetailsAsync()) + _mockRepository.Setup(r => r.GetPaginatedWithFilterAsync(null, null, null, null, null)) .ThrowsAsync(exception); // Act var result = await _controller.GetAllModels(); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while retrieving models"); - - // Verify logging occurred - _mockLogger.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error getting all models")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion @@ -172,8 +161,8 @@ public async Task GetModelById_WithValidId_ShouldReturnOkWithModelDto() var result = await _controller.GetModelById(modelId); // Assert - var okResult = Assert.IsType(result); - var dto = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var dto = okResult.Value.Should().BeOfType().Subject; dto.Id.Should().Be(modelId); dto.Name.Should().Be("test-model"); dto.IsActive.Should().BeTrue(); @@ -195,8 +184,9 @@ public async Task GetModelById_WithNonExistentId_ShouldReturnNotFound() var result = await _controller.GetModelById(modelId); // Assert - var notFoundResult = Assert.IsType(result); - notFoundResult.Value.Should().Be($"Model with ID {modelId} not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); _mockRepository.Verify(r => r.GetByIdWithDetailsAsync(modelId), Times.Once); } @@ -214,19 +204,10 @@ public async Task GetModelById_WhenRepositoryThrows_ShouldReturn500() var result = await _controller.GetModelById(modelId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while retrieving the model"); - - // Verify logging occurred - _mockLogger.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error getting model with ID")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion @@ -284,9 +265,8 @@ public async Task GetModelIdentifiers_WithValidId_ShouldReturnOkWithIdentifiers( var result = await _controller.GetModelIdentifiers(modelId); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var identifiers = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var identifiers = okResult.Value.Should().BeAssignableTo>().Subject; identifiers.Should().HaveCount(3); // Verify the structure by serializing to JSON and deserializing @@ -332,9 +312,8 @@ public async Task GetModelIdentifiers_WithModelWithoutIdentifiers_ShouldReturnEm var result = await _controller.GetModelIdentifiers(modelId); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var identifiers = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var identifiers = okResult.Value.Should().BeAssignableTo>().Subject; identifiers.Should().BeEmpty(); _mockRepository.Verify(r => r.GetByIdWithDetailsAsync(modelId), Times.Once); @@ -352,8 +331,9 @@ public async Task GetModelIdentifiers_WithNonExistentId_ShouldReturnNotFound() var result = await _controller.GetModelIdentifiers(modelId); // Assert - var notFoundResult = Assert.IsType(result); - notFoundResult.Value.Should().Be($"Model with ID {modelId} not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); _mockRepository.Verify(r => r.GetByIdWithDetailsAsync(modelId), Times.Once); } @@ -371,19 +351,10 @@ public async Task GetModelIdentifiers_WhenRepositoryThrows_ShouldReturn500() var result = await _controller.GetModelIdentifiers(modelId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while retrieving model identifiers"); - - // Verify logging occurred - _mockLogger.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error getting identifiers for model with ID")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.ProviderOperations.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.ProviderOperations.cs index b8eea4f06..110ade113 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.ProviderOperations.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelControllerTests.ProviderOperations.cs @@ -2,6 +2,7 @@ using ConduitLLM.Admin.Interfaces; using ConduitLLM.Admin.Models.Models; using ConduitLLM.Configuration; +using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Configuration.Repositories; @@ -110,9 +111,8 @@ public async Task GetModelsByProvider_WithValidProvider_ShouldReturnOkWithModels var result = await _controller.GetModelsByProvider(provider); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var dtos = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var dtos = okResult.Value.Should().BeAssignableTo>().Subject; dtos.Should().HaveCount(2); var firstDto = dtos.First(); @@ -139,7 +139,7 @@ public async Task GetModelsByProvider_WithEmptyProvider_ShouldReturnBadRequest() var result = await _controller.GetModelsByProvider(provider); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("Provider name is required"); _mockRepository.Verify(r => r.GetByProviderAsync(It.IsAny()), Times.Never); @@ -155,7 +155,7 @@ public async Task GetModelsByProvider_WithNullProvider_ShouldReturnBadRequest() var result = await _controller.GetModelsByProvider(provider); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("Provider name is required"); _mockRepository.Verify(r => r.GetByProviderAsync(It.IsAny()), Times.Never); @@ -171,7 +171,7 @@ public async Task GetModelsByProvider_WithWhitespaceProvider_ShouldReturnBadRequ var result = await _controller.GetModelsByProvider(provider); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("Provider name is required"); _mockRepository.Verify(r => r.GetByProviderAsync(It.IsAny()), Times.Never); @@ -235,9 +235,8 @@ public async Task GetModelsByProvider_WithModelHavingProviderIdentifier_ShouldRe var result = await _controller.GetModelsByProvider(provider); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var dtos = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var dtos = okResult.Value.Should().BeAssignableTo>().Subject; var dto = dtos.First(); // Should use the groq-specific identifier @@ -282,9 +281,8 @@ public async Task GetModelsByProvider_WithCaseInsensitiveProviderMatch_ShouldRet var result = await _controller.GetModelsByProvider(provider); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var dtos = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var dtos = okResult.Value.Should().BeAssignableTo>().Subject; var dto = dtos.First(); // Should match case-insensitively @@ -297,7 +295,7 @@ public async Task GetModelsByProvider_WhenRepositoryThrows_ShouldReturn500() // Arrange var provider = "groq"; var exception = new Exception("Database connection failed"); - + _mockRepository.Setup(r => r.GetByProviderAsync(ProviderType.Groq)) .ThrowsAsync(exception); @@ -305,19 +303,10 @@ public async Task GetModelsByProvider_WhenRepositoryThrows_ShouldReturn500() var result = await _controller.GetModelsByProvider(provider); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - objectResult.Value.Should().Be("An error occurred while retrieving models"); - - // Verify logging occurred - _mockLogger.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Error getting models for provider")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } [Fact] @@ -359,9 +348,8 @@ public async Task GetModelsByProvider_WithNullCapabilities_ShouldHandleGracefull var result = await _controller.GetModelsByProvider(provider); // Assert - var okResult = Assert.IsType(result); - okResult.Value.Should().BeAssignableTo>(); - var dtos = (IEnumerable)okResult.Value; + var okResult = result.Should().BeOfType().Subject; + var dtos = okResult.Value.Should().BeAssignableTo>().Subject; var dto = dtos.First(); // After consolidation, capability fields have default values diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.CRUD.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.CRUD.cs index d8303100f..847d3f792 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.CRUD.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.CRUD.cs @@ -3,6 +3,7 @@ using FluentAssertions; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; @@ -67,11 +68,11 @@ public async Task CreateModelCost_WithValidData_ShouldReturnCreated() var result = await _controller.CreateModelCost(createDto); // Assert - var createdResult = Assert.IsType(result); + var createdResult = result.Should().BeOfType().Subject; createdResult.ActionName.Should().Be(nameof(ModelCostsController.GetModelCostById)); createdResult.RouteValues!["id"].Should().Be(10); - - var returnedCost = Assert.IsType(createdResult.Value); + + var returnedCost = createdResult.Value.Should().BeOfType().Subject; returnedCost.CostName.Should().Be("New Model Pricing"); } @@ -92,8 +93,10 @@ public async Task CreateModelCost_WithDuplicateCostName_ShouldReturnBadRequest() var result = await _controller.CreateModelCost(createDto); // Assert - var badRequestResult = Assert.IsType(result); - badRequestResult.Value.Should().Be("Model cost with this name already exists"); + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + errorResponse.error.ToString().Should().Be("The requested operation is not valid"); + errorResponse.Code.Should().Be("invalid_operation"); } #endregion @@ -119,7 +122,7 @@ public async Task UpdateModelCost_WithValidData_ShouldReturnNoContent() var result = await _controller.UpdateModelCost(1, updateDto); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -137,7 +140,7 @@ public async Task UpdateModelCost_WithMismatchedIds_ShouldReturnBadRequest() var result = await _controller.UpdateModelCost(1, updateDto); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("ID in route must match ID in body"); } @@ -159,9 +162,9 @@ public async Task UpdateModelCost_WithNonExistingId_ShouldReturnNotFound() var result = await _controller.UpdateModelCost(999, updateDto); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Model cost not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion @@ -179,7 +182,7 @@ public async Task DeleteModelCost_WithExistingId_ShouldReturnNoContent() var result = await _controller.DeleteModelCost(1); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -193,9 +196,9 @@ public async Task DeleteModelCost_WithNonExistingId_ShouldReturnNotFound() var result = await _controller.DeleteModelCost(999); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Model cost not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.ImportExport.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.ImportExport.cs index efc66b16c..5c2740142 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.ImportExport.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.ImportExport.cs @@ -33,7 +33,7 @@ public async Task ImportModelCosts_WithValidList_ShouldReturnCount() var result = await _controller.ImportModelCosts(modelCosts); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; okResult.Value.Should().Be(2); } @@ -44,7 +44,7 @@ public async Task ImportModelCosts_WithEmptyList_ShouldReturnBadRequest() var result = await _controller.ImportModelCosts(new List()); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("No model costs provided for import"); } @@ -64,7 +64,7 @@ public async Task ExportCsv_ShouldReturnCsvFile() var result = await _controller.ExportCsv(); // Assert - var fileResult = Assert.IsType(result); + var fileResult = result.Should().BeOfType().Subject; fileResult.ContentType.Should().Be("text/csv"); fileResult.FileDownloadName.Should().StartWith("model-costs-"); fileResult.FileDownloadName.Should().EndWith(".csv"); @@ -85,7 +85,7 @@ public async Task ExportJson_WithProvider_ShouldReturnFilteredJsonFile() var result = await _controller.ExportJson(1); // Assert - var fileResult = Assert.IsType(result); + var fileResult = result.Should().BeOfType().Subject; fileResult.ContentType.Should().Be("application/json"); fileResult.FileDownloadName.Should().StartWith("model-costs-"); fileResult.FileDownloadName.Should().EndWith(".json"); @@ -121,8 +121,8 @@ public async Task ImportCsv_WithValidFile_ShouldReturnImportResult() var result = await _controller.ImportCsv(formFile); // Assert - var okResult = Assert.IsType(result); - var returnedResult = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedResult = okResult.Value.Should().BeOfType().Subject; returnedResult.SuccessCount.Should().Be(1); returnedResult.FailureCount.Should().Be(0); } @@ -143,7 +143,7 @@ public async Task ImportCsv_WithInvalidFileType_ShouldReturnBadRequest() var result = await _controller.ImportCsv(formFile); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; var errorObj = badRequestResult.Value as dynamic; ((string)errorObj.error).Should().Be("File must be a CSV file"); } @@ -175,7 +175,7 @@ public async Task ImportJson_WithFailedImport_ShouldReturnBadRequest() // Assert // The test should just verify it returns BadRequest - the exact format depends on the controller implementation - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().NotBeNull(); } diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.Read.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.Read.cs index 770d6be3c..6032fd130 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.Read.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelCostsControllerTests.Read.cs @@ -1,9 +1,7 @@ -using ConduitLLM.Tests.Admin.TestHelpers; using ConduitLLM.Configuration.DTOs; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; namespace ConduitLLM.Tests.Admin.Controllers @@ -29,8 +27,8 @@ public async Task GetAllModelCosts_WithCosts_ShouldReturnOkWithList() var result = await _controller.GetAllModelCosts(); // Assert - var okResult = Assert.IsType(result); - var returnedCosts = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedCosts = okResult.Value.Should().BeAssignableTo>().Subject; returnedCosts.Should().HaveCount(2); returnedCosts.First().CostName.Should().Be("GPT-4 Pricing"); } @@ -52,9 +50,9 @@ public async Task GetAllModelCosts_WithPagination_ShouldReturnPaginatedResponse( var result = await _controller.GetAllModelCosts(page: 2, pageSize: 10); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; okResult.Value.Should().NotBeNull(); - + // Use reflection to access anonymous type properties var responseType = okResult.Value!.GetType(); var totalCount = (int)responseType.GetProperty("totalCount")!.GetValue(okResult.Value)!; @@ -62,13 +60,13 @@ public async Task GetAllModelCosts_WithPagination_ShouldReturnPaginatedResponse( var pageSize = (int)responseType.GetProperty("pageSize")!.GetValue(okResult.Value)!; var totalPages = (int)responseType.GetProperty("totalPages")!.GetValue(okResult.Value)!; var items = responseType.GetProperty("items")!.GetValue(okResult.Value) as IEnumerable; - + // Verify pagination metadata totalCount.Should().Be(25); page.Should().Be(2); pageSize.Should().Be(10); totalPages.Should().Be(3); - + // Verify items items.Should().NotBeNull(); items!.Count().Should().Be(10); @@ -93,9 +91,9 @@ public async Task GetAllModelCosts_WithPaginationLastPage_ShouldReturnPartialPag var result = await _controller.GetAllModelCosts(page: 3, pageSize: 10); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; okResult.Value.Should().NotBeNull(); - + // Use reflection to access anonymous type properties var responseType = okResult.Value!.GetType(); var totalCount = (int)responseType.GetProperty("totalCount")!.GetValue(okResult.Value)!; @@ -103,13 +101,13 @@ public async Task GetAllModelCosts_WithPaginationLastPage_ShouldReturnPartialPag var pageSize = (int)responseType.GetProperty("pageSize")!.GetValue(okResult.Value)!; var totalPages = (int)responseType.GetProperty("totalPages")!.GetValue(okResult.Value)!; var items = responseType.GetProperty("items")!.GetValue(okResult.Value) as IEnumerable; - + // Verify pagination metadata totalCount.Should().Be(25); page.Should().Be(3); pageSize.Should().Be(10); totalPages.Should().Be(3); - + // Verify items - should only have 5 items on last page items.Should().NotBeNull(); items!.Count().Should().Be(5); @@ -134,8 +132,8 @@ public async Task GetAllModelCosts_WithOnlyPageParameter_ShouldReturnAllItems() var result = await _controller.GetAllModelCosts(page: 1, pageSize: null); // Assert - Should return all items without pagination - var okResult = Assert.IsType(result); - var returnedCosts = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedCosts = okResult.Value.Should().BeAssignableTo>().Subject; returnedCosts.Should().HaveCount(2); } @@ -150,10 +148,10 @@ public async Task GetAllModelCosts_WithException_ShouldReturn500() var result = await _controller.GetAllModelCosts(); // Assert - var statusCodeResult = Assert.IsType(result); + var statusCodeResult = result.Should().BeOfType().Subject; statusCodeResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - - _mockLogger.VerifyLogWithAnyException(LogLevel.Error, "Error getting all model costs"); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion @@ -179,8 +177,8 @@ public async Task GetModelCostById_WithExistingId_ShouldReturnOkWithCost() var result = await _controller.GetModelCostById(1); // Assert - var okResult = Assert.IsType(result); - var returnedCost = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedCost = okResult.Value.Should().BeOfType().Subject; returnedCost.Id.Should().Be(1); returnedCost.InputCostPerMillionTokens.Should().Be(30.00m); } @@ -196,9 +194,9 @@ public async Task GetModelCostById_WithNonExistingId_ShouldReturnNotFound() var result = await _controller.GetModelCostById(999); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Model cost not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion @@ -222,8 +220,8 @@ public async Task GetModelCostsByProvider_WithExistingProvider_ShouldReturnCosts var result = await _controller.GetModelCostsByProvider(1); // Assert - var okResult = Assert.IsType(result); - var returnedCosts = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedCosts = okResult.Value.Should().BeAssignableTo>().Subject; returnedCosts.Should().HaveCount(2); } @@ -238,8 +236,8 @@ public async Task GetModelCostsByProvider_WithEmptyProvider_ShouldReturnEmptyLis var result = await _controller.GetModelCostsByProvider(999); // Assert - var okResult = Assert.IsType(result); - var returnedCosts = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedCosts = okResult.Value.Should().BeAssignableTo>().Subject; returnedCosts.Should().BeEmpty(); } @@ -265,8 +263,8 @@ public async Task GetModelCostByCostName_WithMatchingCostName_ShouldReturnCost() var result = await _controller.GetModelCostByCostName("GPT-4 Turbo Pricing"); // Assert - var okResult = Assert.IsType(result); - var returnedCost = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedCost = okResult.Value.Should().BeOfType().Subject; returnedCost.CostName.Should().Be("GPT-4 Turbo Pricing"); } @@ -281,9 +279,9 @@ public async Task GetModelCostByCostName_WithNoMatch_ShouldReturnNotFound() var result = await _controller.GetModelCostByCostName("Unknown Cost"); // Assert - var notFoundResult = Assert.IsType(result); - var errorObj = notFoundResult.Value as dynamic; - ((string)errorObj.error).Should().Be("Model cost not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion @@ -310,8 +308,8 @@ public async Task GetModelCostOverview_WithValidDates_ShouldReturnOverview() var result = await _controller.GetModelCostOverview(startDate, endDate); // Assert - var okResult = Assert.IsType(result); - var returnedOverview = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedOverview = okResult.Value.Should().BeAssignableTo>().Subject; returnedOverview.Should().HaveCount(2); returnedOverview.Sum(o => o.TotalCost).Should().Be(351.25m); } @@ -327,7 +325,7 @@ public async Task GetModelCostOverview_WithInvalidDates_ShouldReturnBadRequest() var result = await _controller.GetModelCostOverview(startDate, endDate); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; badRequestResult.Value.Should().Be("Start date cannot be after end date"); } diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.AddMapping.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.AddMapping.cs index 447b4b389..befa90825 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.AddMapping.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.AddMapping.cs @@ -55,7 +55,7 @@ public async Task AddMapping_WithValidMapping_ShouldReturnCreated() var actionResult = await _controller.CreateMapping(mapping.ToDto()); // Assert - var createdResult = Assert.IsType(actionResult); + var createdResult = actionResult.Should().BeOfType().Subject; createdResult.ActionName.Should().Be(nameof(ModelProviderMappingController.GetMappingById)); createdResult.RouteValues!["id"].Should().Be(123); } @@ -89,8 +89,8 @@ public async Task AddMapping_WithDuplicateModelId_ShouldReturnConflict() var actionResult = await _controller.CreateMapping(mapping.ToDto()); // Assert - var conflictResult = Assert.IsType(actionResult); - var errorResponse = Assert.IsType(conflictResult.Value); + var conflictResult = actionResult.Should().BeOfType().Subject; + var errorResponse = conflictResult.Value.Should().BeOfType().Subject; errorResponse.error.ToString().Should().Contain("A mapping for model alias 'existing-model' already exists"); } @@ -118,8 +118,8 @@ public async Task AddMapping_WithInvalidProviderId_ShouldReturnBadRequest() var actionResult = await _controller.CreateMapping(mapping.ToDto()); // Assert - var badRequestResult = Assert.IsType(actionResult); - var errorResponse = Assert.IsType(badRequestResult.Value); + var badRequestResult = actionResult.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; errorResponse.error.ToString().Should().Contain("Failed to create"); } diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.BulkOperations.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.BulkOperations.cs index 9b682ba05..056cc77ea 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.BulkOperations.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.BulkOperations.cs @@ -37,8 +37,8 @@ public async Task GetProviders_ShouldReturnProviderList() var result = await _controller.GetProviders(); // Assert - var okResult = Assert.IsType(result); - var returnedProviders = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedProviders = okResult.Value.Should().BeAssignableTo>().Subject; returnedProviders.Should().HaveCount(3); } @@ -82,8 +82,8 @@ public async Task BulkCreateMappings_WithValidMappings_ShouldReturnSuccess() var result = await _controller.CreateBulkMappings(mappings.Select(m => m.ToDto()).ToList()); // Assert - var okResult = Assert.IsType(result); - var returnedResponse = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedResponse = okResult.Value.Should().BeOfType().Subject; returnedResponse.TotalProcessed.Should().Be(2); returnedResponse.Created.Should().HaveCount(2); returnedResponse.SuccessCount.Should().Be(2); @@ -117,8 +117,8 @@ public async Task BulkCreateMappings_WithSomeFailures_ShouldReturnPartialSuccess var result = await _controller.CreateBulkMappings(mappings.Select(m => m.ToDto()).ToList()); // Assert - var okResult = Assert.IsType(result); - var returnedResponse = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedResponse = okResult.Value.Should().BeOfType().Subject; returnedResponse.SuccessCount.Should().Be(1); returnedResponse.FailureCount.Should().Be(2); } @@ -133,8 +133,8 @@ public async Task BulkCreateMappings_WithEmptyRequest_ShouldReturnBadRequest() var result = await _controller.CreateBulkMappings(mappings.Select(m => m.ToDto()).ToList()); // Assert - var badRequestResult = Assert.IsType(result); - var errorResponse = Assert.IsType(badRequestResult.Value); + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; errorResponse.error.ToString().Should().Be("No mappings provided"); } @@ -157,8 +157,8 @@ public async Task BulkCreateMappings_WithExistingModels_ShouldReturnErrors() var result = await _controller.CreateBulkMappings(mappings.Select(m => m.ToDto()).ToList()); // Assert - var okResult = Assert.IsType(result); - var returnedResponse = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedResponse = okResult.Value.Should().BeOfType().Subject; returnedResponse.Errors.Should().HaveCount(1); returnedResponse.Created.Should().BeEmpty(); } diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.DeleteMapping.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.DeleteMapping.cs index 3ec398385..aaf19ba19 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.DeleteMapping.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.DeleteMapping.cs @@ -30,7 +30,7 @@ public async Task DeleteMapping_WithExistingId_ShouldReturnNoContent() var result = await _controller.DeleteMapping(1); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -44,9 +44,9 @@ public async Task DeleteMapping_WithNonExistingId_ShouldReturnNotFound() var result = await _controller.DeleteMapping(999); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - errorResponse.error.ToString().Should().Be("Model provider mapping not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.GetMappings.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.GetMappings.cs index 3f75035f6..69281c146 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.GetMappings.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.GetMappings.cs @@ -1,12 +1,10 @@ using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; -using ConduitLLM.Tests.Admin.TestHelpers; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; @@ -52,8 +50,8 @@ public async Task GetAllMappings_WithMappings_ShouldReturnOkWithList() var result = await _controller.GetAllMappings(); // Assert - var okResult = Assert.IsType(result); - var returnedMappings = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedMappings = okResult.Value.Should().BeAssignableTo>().Subject; returnedMappings.Should().HaveCount(2); returnedMappings.First().ModelProviderTypeAssociationId.Should().Be(1); } @@ -69,10 +67,10 @@ public async Task GetAllMappings_WithException_ShouldReturn500() var result = await _controller.GetAllMappings(); // Assert - var statusCodeResult = Assert.IsType(result); + var statusCodeResult = result.Should().BeOfType().Subject; statusCodeResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - - _mockLogger.VerifyLogWithAnyException(LogLevel.Error, "Error getting all model provider mappings"); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("internal_error"); } #endregion @@ -99,8 +97,8 @@ public async Task GetMappingById_WithExistingId_ShouldReturnOkWithMapping() var result = await _controller.GetMappingById(1); // Assert - var okResult = Assert.IsType(result); - var returnedMapping = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var returnedMapping = okResult.Value.Should().BeOfType().Subject; returnedMapping.ModelProviderTypeAssociationId.Should().Be(1); } @@ -115,9 +113,9 @@ public async Task GetMappingById_WithNonExistingId_ShouldReturnNotFound() var result = await _controller.GetMappingById(999); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - errorResponse.error.ToString().Should().Be("Model provider mapping not found"); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.UpdateMapping.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.UpdateMapping.cs index b678f7409..5136c818c 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.UpdateMapping.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ModelProviderMappingControllerTests.UpdateMapping.cs @@ -41,7 +41,7 @@ public async Task UpdateMapping_WithValidMapping_ShouldReturnNoContent() var actionResult = await _controller.UpdateMapping(1, mapping.ToDto()); // Assert - Assert.IsType(actionResult); + actionResult.Should().BeOfType(); } [Fact] @@ -65,9 +65,9 @@ public async Task UpdateMapping_WithNonExistingId_ShouldReturnNotFound() var actionResult = await _controller.UpdateMapping(999, mapping.ToDto()); // Assert - var notFoundResult = Assert.IsType(actionResult); - var errorResponse = Assert.IsType(notFoundResult.Value); - errorResponse.error.ToString().Should().Be("Model provider mapping not found"); + var notFoundResult = actionResult.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + errorResponse.Code.Should().Be("not_found"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/PricingControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/PricingControllerTests.cs new file mode 100644 index 000000000..c7b79ce80 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/PricingControllerTests.cs @@ -0,0 +1,268 @@ +using ConduitLLM.Admin.Controllers; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Models.Pricing; +using ConduitLLM.Core.Services; + +using FluentAssertions; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +using Moq; + +using Xunit.Abstractions; + +using ValidationResult = ConduitLLM.Core.Services.ValidationResult; + +namespace ConduitLLM.Tests.Admin.Controllers +{ + [Trait("Category", "Unit")] + [Trait("Component", "AdminController")] + public class PricingControllerTests + { + private readonly Mock _mockValidator; + private readonly Mock _mockEvaluator; + private readonly Mock _mockAuditService; + private readonly Mock> _mockLogger; + private readonly PricingController _controller; + private readonly ITestOutputHelper _output; + + public PricingControllerTests(ITestOutputHelper output) + { + _output = output; + _mockValidator = new Mock(); + _mockEvaluator = new Mock(); + _mockAuditService = new Mock(); + _mockLogger = new Mock>(); + _controller = new PricingController( + _mockValidator.Object, + _mockEvaluator.Object, + _mockAuditService.Object, + _mockLogger.Object); + } + + #region ValidatePricingConfiguration Tests + + [Fact] + public async Task ValidatePricingConfiguration_ValidConfig_ReturnsOkAndLogsAudit() + { + // Arrange + var request = new PricingValidationRequest + { + PricingConfiguration = "{\"pricingType\":\"per_second\",\"defaultRate\":0.025}" + }; + + _mockValidator.Setup(v => v.Validate(It.IsAny(), null)) + .Returns(new ValidationResult()); + + // Act + var result = await _controller.ValidatePricingConfiguration(request); + + // Assert + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + response.IsValid.Should().BeTrue(); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Admin Audit") && o.ToString()!.Contains("Validated")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ValidatePricingConfiguration_InvalidJson_ReturnsOkWithErrors() + { + // Arrange + var request = new PricingValidationRequest + { + PricingConfiguration = "not valid json" + }; + + // Act + var result = await _controller.ValidatePricingConfiguration(request); + + // Assert + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + response.IsValid.Should().BeFalse(); + response.Errors.Should().NotBeEmpty(); + } + + #endregion + + #region SimulatePricing Tests + + [Fact] + public async Task SimulatePricing_ValidRequest_ReturnsOkAndLogsAudit() + { + // Arrange + var request = new PricingSimulationRequest + { + PricingConfiguration = "{\"pricingType\":\"per_second\",\"defaultRate\":0.025}", + VideoDurationSeconds = 10.0 + }; + + _mockValidator.Setup(v => v.Validate(It.IsAny(), null)) + .Returns(new ValidationResult()); + + _mockEvaluator.Setup(e => e.Evaluate( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(new PricingEvaluationResult + { + Cost = 0.25m, + Rate = 0.025m, + Quantity = 10m, + UsedDefaultRate = false + }); + + // Act + var result = await _controller.SimulatePricing(request); + + // Assert + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + response.CalculatedCost.Should().Be(0.25m); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Admin Audit") && o.ToString()!.Contains("Simulated")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task SimulatePricing_InvalidJson_ReturnsBadRequest() + { + // Arrange + var request = new PricingSimulationRequest + { + PricingConfiguration = "bad json" + }; + + // Act + var result = await _controller.SimulatePricing(request); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region QueryPricingAuditEvents Tests + + [Fact] + public async Task QueryPricingAuditEvents_ValidRequest_ReturnsOkAndLogsAudit() + { + // Arrange + var request = new PricingAuditQueryRequest + { + From = DateTime.UtcNow.AddDays(-7), + To = DateTime.UtcNow, + PageNumber = 1, + PageSize = 50 + }; + + _mockAuditService.Setup(s => s.GetAuditEventsAsync( + It.IsAny(), It.IsAny(), + null, null, null, 1, 50)) + .ReturnsAsync((new List(), 0)); + + // Act + var result = await _controller.QueryPricingAuditEvents(request); + + // Assert + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + response.TotalCount.Should().Be(0); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Admin Audit") && o.ToString()!.Contains("Queried")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task QueryPricingAuditEvents_InvalidDateRange_ReturnsBadRequest() + { + // Arrange + var request = new PricingAuditQueryRequest + { + From = DateTime.UtcNow, + To = DateTime.UtcNow.AddDays(-7) // From > To + }; + + // Act + var result = await _controller.QueryPricingAuditEvents(request); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task QueryPricingAuditEvents_PageSizeTooLarge_ReturnsBadRequest() + { + // Arrange + var request = new PricingAuditQueryRequest + { + From = DateTime.UtcNow.AddDays(-1), + To = DateTime.UtcNow, + PageSize = 1001 + }; + + // Act + var result = await _controller.QueryPricingAuditEvents(request); + + // Assert + result.Should().BeOfType(); + } + + #endregion + + #region GetPricingTypes Tests + + [Fact] + public void GetPricingTypes_ReturnsOkWithTypes() + { + // Act + var result = _controller.GetPricingTypes(); + + // Assert + var okResult = result.Should().BeOfType().Subject; + okResult.Value.Should().NotBeNull(); + } + + #endregion + + #region GetPricingAuditByRequestId Tests + + [Fact] + public async Task GetPricingAuditByRequestId_NoEvents_ReturnsNotFound() + { + // Arrange + _mockAuditService.Setup(s => s.GetByRequestIdAsync("req-123")) + .ReturnsAsync(new List()); + + // Act + var result = await _controller.GetPricingAuditByRequestId("req-123"); + + // Assert + result.Should().BeOfType(); + } + + #endregion + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/PromptCachingControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/PromptCachingControllerTests.cs new file mode 100644 index 000000000..7c6b38d21 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/PromptCachingControllerTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; + +using ConduitLLM.Admin.Controllers; +using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.DTOs.PromptCaching; +using ConduitLLM.Configuration.Interfaces; + +using FluentAssertions; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +using Moq; + +namespace ConduitLLM.Tests.Admin.Controllers; + +public class PromptCachingControllerTests +{ + private readonly Mock _mockSettingService; + private readonly Mock _mockCacheService; + private readonly PromptCachingController _controller; + + public PromptCachingControllerTests() + { + _mockSettingService = new Mock(); + _mockCacheService = new Mock(); + var mockLogger = new Mock>(); + + _controller = new PromptCachingController( + _mockSettingService.Object, + _mockCacheService.Object, + mockLogger.Object); + + // Set up HttpContext for audit logging + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + } + + [Fact] + public async Task GetConfig_NoSetting_ReturnsDefaults() + { + // Arrange + _mockCacheService.Setup(x => x.GetSettingValueAsync("PromptCaching.Config")) + .ReturnsAsync((string?)null); + + // Act + var result = await _controller.GetConfig(); + + // Assert + var okResult = result.Should().BeOfType().Subject; + var config = okResult.Value.Should().BeOfType().Subject; + config.AutoInjectEnabled.Should().BeFalse(); + config.InjectionPoints.Should().BeEmpty(); + } + + [Fact] + public async Task GetConfig_ExistingSetting_ReturnsConfig() + { + // Arrange + var json = """{"auto_inject_enabled":true,"injection_points":[{"role":"system","index":0}]}"""; + _mockCacheService.Setup(x => x.GetSettingValueAsync("PromptCaching.Config")) + .ReturnsAsync(json); + + // Act + var result = await _controller.GetConfig(); + + // Assert + var okResult = result.Should().BeOfType().Subject; + var config = okResult.Value.Should().BeOfType().Subject; + config.AutoInjectEnabled.Should().BeTrue(); + config.InjectionPoints.Should().HaveCount(1); + config.InjectionPoints[0].Role.Should().Be("system"); + config.InjectionPoints[0].Index.Should().Be(0); + } + + [Fact] + public async Task UpdateConfig_ValidInput_SavesAndInvalidatesCache() + { + // Arrange + var dto = new UpdatePromptCachingConfigDto + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system", Index = 0 }, + new() { Role = "user", Index = -1 } + } + }; + + _mockSettingService.Setup(x => x.GetSettingByKeyAsync("PromptCaching.Config")) + .ReturnsAsync(new GlobalSettingDto { Id = 1, Key = "PromptCaching.Config", Value = "{}" }); + + _mockSettingService.Setup(x => x.UpdateSettingByKeyAsync(It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _controller.UpdateConfig(dto); + + // Assert + var okResult = result.Should().BeOfType().Subject; + var config = okResult.Value.Should().BeOfType().Subject; + config.AutoInjectEnabled.Should().BeTrue(); + config.InjectionPoints.Should().HaveCount(2); + + // Verify cache was invalidated + _mockCacheService.Verify(x => x.InvalidateSettingAsync("PromptCaching.Config"), Times.Once); + + // Verify setting was updated (not created) + _mockSettingService.Verify(x => x.UpdateSettingByKeyAsync(It.Is( + s => s.Key == "PromptCaching.Config")), Times.Once); + _mockSettingService.Verify(x => x.CreateSettingAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateConfig_NoExistingSetting_CreatesNew() + { + // Arrange + var dto = new UpdatePromptCachingConfigDto + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system", Index = 0 } + } + }; + + _mockSettingService.Setup(x => x.GetSettingByKeyAsync("PromptCaching.Config")) + .ReturnsAsync((GlobalSettingDto?)null); + + _mockSettingService.Setup(x => x.CreateSettingAsync(It.IsAny())) + .ReturnsAsync(new GlobalSettingDto { Id = 1, Key = "PromptCaching.Config", Value = "{}" }); + + // Act + var result = await _controller.UpdateConfig(dto); + + // Assert + result.Should().BeOfType(); + + // Verify setting was created (not updated) + _mockSettingService.Verify(x => x.CreateSettingAsync(It.Is( + s => s.Key == "PromptCaching.Config")), Times.Once); + _mockSettingService.Verify(x => x.UpdateSettingByKeyAsync(It.IsAny()), Times.Never); + + // Verify cache was invalidated + _mockCacheService.Verify(x => x.InvalidateSettingAsync("PromptCaching.Config"), Times.Once); + } + + [Fact] + public void UpdateConfig_TooManyInjectionPoints_FailsValidation() + { + // Arrange โ€” 5 injection points exceeds the MaxLength(4) limit + var dto = new UpdatePromptCachingConfigDto + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system", Index = 0 }, + new() { Role = "user", Index = -1 }, + new() { Role = "user", Index = -2 }, + new() { Role = "assistant", Index = -1 }, + new() { Role = "system", Index = 1 } + } + }; + + // Act โ€” validate using DataAnnotations + var context = new System.ComponentModel.DataAnnotations.ValidationContext(dto); + var results = new List(); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject(dto, context, results, true); + + // Assert + isValid.Should().BeFalse(); + results.Should().Contain(r => r.ErrorMessage!.Contains("Maximum 4 injection points")); + } + + [Fact] + public void UpdateConfig_InvalidRole_FailsValidation() + { + // Arrange โ€” "admin" is not a valid role + var point = new CacheInjectionPointDto + { + Role = "admin", + Index = 0 + }; + + // Act โ€” validate the injection point + var context = new System.ComponentModel.DataAnnotations.ValidationContext(point); + var results = new List(); + var isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateObject(point, context, results, true); + + // Assert + isValid.Should().BeFalse(); + results.Should().Contain(r => r.ErrorMessage!.Contains("Role must be system, user, or assistant")); + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/ProviderCredentialsControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/ProviderCredentialsControllerTests.cs index e33ffa9ab..19bed390f 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/ProviderCredentialsControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/ProviderCredentialsControllerTests.cs @@ -4,6 +4,7 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Exceptions; using ConduitLLM.Core.Interfaces; +using FluentAssertions; using MassTransit; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -98,9 +99,9 @@ public async Task TestProviderConnectionWithCredentials_WithValidCredentials_Sho var result = await _controller.TestProviderConnectionWithCredentials(testRequest); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value!); - + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + Assert.Equal(ApiKeyTestResult.Success, response.Result); Assert.Contains("authorized", response.Message); Assert.NotNull(response.Details?.ModelsAvailable); @@ -127,9 +128,9 @@ public async Task TestProviderConnectionWithCredentials_WithInvalidApiKey_Should var result = await _controller.TestProviderConnectionWithCredentials(testRequest); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value!); - + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + Assert.Equal(ApiKeyTestResult.InvalidKey, response.Result); Assert.Contains("authorization test", response.Message); Assert.NotNull(response.Details); @@ -156,9 +157,9 @@ public async Task TestProviderConnectionWithCredentials_WithUnauthorizedError_Sh var result = await _controller.TestProviderConnectionWithCredentials(testRequest); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value!); - + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + Assert.Equal(ApiKeyTestResult.InvalidKey, response.Result); Assert.Contains("authorization test", response.Message); Assert.NotNull(response.Details); @@ -187,9 +188,9 @@ public async Task TestProviderConnectionWithCredentials_DoesNotReturnFallbackMod var result = await _controller.TestProviderConnectionWithCredentials(testRequest); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value!); - + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + // Verify the connection test properly fails (no fallback models returned) Assert.Equal(ApiKeyTestResult.InvalidKey, response.Result); Assert.Contains("authorization test", response.Message); @@ -201,7 +202,7 @@ public async Task TestProviderConnectionWithCredentials_DoesNotReturnFallbackMod } [Fact] - public async Task TestProviderConnectionWithCredentials_WithEmptyApiKey_ShouldReturnInternalServerError() + public async Task TestProviderConnectionWithCredentials_WithEmptyApiKey_ShouldReturnBadRequest() { // Arrange var testRequest = new TestProviderRequest @@ -218,14 +219,14 @@ public async Task TestProviderConnectionWithCredentials_WithEmptyApiKey_ShouldRe // Act var result = await _controller.TestProviderConnectionWithCredentials(testRequest); - // Assert - Client factory exceptions result in 500 Internal Server Error - var statusResult = Assert.IsType(result); - Assert.Equal(500, statusResult.StatusCode); - Assert.Equal("An unexpected error occurred.", statusResult.Value); + // Assert - ExceptionToResponseMapper maps ArgumentException to 400 Bad Request + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + Assert.Equal("invalid_parameter", errorResponse.Code); } [Fact] - public async Task TestProviderConnectionWithCredentials_WithNullApiKey_ShouldReturnInternalServerError() + public async Task TestProviderConnectionWithCredentials_WithNullApiKey_ShouldReturnBadRequest() { // Arrange var testRequest = new TestProviderRequest @@ -242,10 +243,10 @@ public async Task TestProviderConnectionWithCredentials_WithNullApiKey_ShouldRet // Act var result = await _controller.TestProviderConnectionWithCredentials(testRequest); - // Assert - Client factory exceptions result in 500 Internal Server Error - var statusResult = Assert.IsType(result); - Assert.Equal(500, statusResult.StatusCode); - Assert.Equal("An unexpected error occurred.", statusResult.Value); + // Assert - ExceptionToResponseMapper maps ArgumentException to 400 Bad Request + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + Assert.Equal("invalid_parameter", errorResponse.Code); } [Fact] @@ -268,8 +269,8 @@ public async Task TestProviderConnectionWithCredentials_WithGenericException_Sho var result = await _controller.TestProviderConnectionWithCredentials(testRequest); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value!); + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; Assert.Equal(ApiKeyTestResult.UnknownError, response.Result); Assert.Contains("unexpected error", response.Message); @@ -292,8 +293,8 @@ public async Task TestProviderConnectionWithCredentials_WithSambaNova_ShouldRetu var result = await _controller.TestProviderConnectionWithCredentials(testRequest); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value!); + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; _output.WriteLine($"Result: {response.Result}"); _output.WriteLine($"Message: {response.Message}"); @@ -318,8 +319,8 @@ public async Task TestProviderConnectionWithCredentials_WithReplicate_ShouldRetu var result = await _controller.TestProviderConnectionWithCredentials(testRequest); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value!); + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; Assert.Equal(ApiKeyTestResult.Ignored, response.Result); Assert.Contains("untested", response.Message, StringComparison.OrdinalIgnoreCase); diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/SystemInfoControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/SystemInfoControllerTests.cs index c4b567516..3f0c645d0 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/SystemInfoControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/SystemInfoControllerTests.cs @@ -2,6 +2,7 @@ using ConduitLLM.Admin.Controllers; using ConduitLLM.Admin.Interfaces; using ConduitLLM.Core.Events; +using FluentAssertions; using MassTransit; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -43,7 +44,7 @@ public async Task InvalidateDiscoveryCache_PublishesEventAndReturnsOkResult() var result = await _controller.InvalidateDiscoveryCache(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); // Verify the event was published @@ -69,7 +70,7 @@ public async Task InvalidateDiscoveryCache_WhenPublishThrowsException_ReturnsInt var result = await _controller.InvalidateDiscoveryCache(); // Assert - var statusResult = Assert.IsType(result); + var statusResult = result.Should().BeOfType().Subject; Assert.Equal(StatusCodes.Status500InternalServerError, statusResult.StatusCode); // Verify error was logged @@ -95,7 +96,7 @@ public async Task InvalidateDiscoveryCache_ReturnsProperResponseStructure() var result = await _controller.InvalidateDiscoveryCache(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; Assert.NotNull(okResult.Value); // Check the response structure using JSON serialization diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/TasksControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/TasksControllerTests.cs index 9ef6a38b7..b6ae5e00e 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/TasksControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/TasksControllerTests.cs @@ -1,6 +1,8 @@ using ConduitLLM.Admin.Controllers; using ConduitLLM.Core.Interfaces; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -53,9 +55,9 @@ public async Task CleanupOldTasks_WithValidRequest_ShouldReturnCleanedUpCount() var result = await _controller.CleanupOldTasks(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; Assert.NotNull(okResult.Value); - + var response = okResult.Value.GetType().GetProperty("cleaned_up")?.GetValue(okResult.Value); var hours = okResult.Value.GetType().GetProperty("older_than_hours")?.GetValue(okResult.Value); @@ -77,7 +79,7 @@ public async Task CleanupOldTasks_WithCustomHours_ShouldUseProvidedValue() var result = await _controller.CleanupOldTasks(olderThanHours); // Assert - Assert.IsType(result); + result.Should().BeOfType(); _mockTaskService.Verify(x => x.CleanupOldTasksAsync(TimeSpan.FromHours(48), It.IsAny()), Times.Once); } @@ -93,7 +95,7 @@ public async Task CleanupOldTasks_WithInvalidHours_ShouldClampToMinimum() var result = await _controller.CleanupOldTasks(olderThanHours); // Assert - Assert.IsType(result); + result.Should().BeOfType(); _mockTaskService.Verify(x => x.CleanupOldTasksAsync(TimeSpan.FromHours(1), It.IsAny()), Times.Once); } @@ -108,19 +110,14 @@ public async Task CleanupOldTasks_WithServiceException_ShouldReturn500() var result = await _controller.CleanupOldTasks(); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); Assert.NotNull(objectResult.Value); - - // Verify the error response structure - var errorProp = objectResult.Value.GetType().GetProperty("error")?.GetValue(objectResult.Value); - Assert.NotNull(errorProp); - - var messageProp = errorProp.GetType().GetProperty("message")?.GetValue(errorProp); - var typeProp = errorProp.GetType().GetProperty("type")?.GetValue(errorProp); - - Assert.Equal("An error occurred while cleaning up tasks", messageProp); - Assert.Equal("server_error", typeProp); + + // Verify standardized error response structure from AdminControllerBase + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("An unexpected error occurred", errorResponse.error); + Assert.Equal("internal_error", errorResponse.Code); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeyGroupsControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeyGroupsControllerTests.cs index dec0c2f2e..b87c6b4fa 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeyGroupsControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeyGroupsControllerTests.cs @@ -1,9 +1,12 @@ using ConduitLLM.Admin.Controllers; using ConduitLLM.Admin.Interfaces; +using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.DTOs.VirtualKey; using ConduitLLM.Configuration.Entities; using ConduitLLM.Configuration.Interfaces; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -96,10 +99,9 @@ public async Task GetKeysInGroup_ShouldReturnVirtualKeys_WhenGroupExists() // Act var result = await _controller.GetKeysInGroup(groupId); - // Assert - var actionResult = Assert.IsType>>(result); - var okResult = Assert.IsType(actionResult.Result); - var keys = Assert.IsType>(okResult.Value); + // Assert - Controller returns IActionResult, not ActionResult + var okResult = result.Should().BeOfType().Subject; + var keys = okResult.Value.Should().BeOfType>().Subject; Assert.Equal(2, keys.Count); Assert.Equal("Test Key 1", keys[0].KeyName); @@ -125,17 +127,8 @@ public async Task GetKeysInGroup_ShouldReturnNotFound_WhenGroupDoesNotExist() // Act var result = await _controller.GetKeysInGroup(groupId); - // Assert - var actionResult = Assert.IsType>>(result); - var notFoundResult = Assert.IsType(actionResult.Result); - - var response = notFoundResult.Value; - Assert.NotNull(response); - - // Use reflection to check the message property - var messageProperty = response.GetType().GetProperty("message"); - Assert.NotNull(messageProperty); - Assert.Equal("Group not found", messageProperty.GetValue(response)); + // Assert - ExecuteWithNotFoundAsync returns NotFoundObjectResult with ErrorResponseDto + result.Should().BeOfType(); // Verify the correct repository method was called _mockGroupRepository.Verify(r => r.GetByIdWithKeysAsync(groupId), Times.Once); @@ -164,10 +157,9 @@ public async Task GetKeysInGroup_ShouldReturnEmptyList_WhenGroupHasNoKeys() // Act var result = await _controller.GetKeysInGroup(groupId); - // Assert - var actionResult = Assert.IsType>>(result); - var okResult = Assert.IsType(actionResult.Result); - var keys = Assert.IsType>(okResult.Value); + // Assert - Controller returns IActionResult, not ActionResult + var okResult = result.Should().BeOfType().Subject; + var keys = okResult.Value.Should().BeOfType>().Subject; Assert.Empty(keys); @@ -176,7 +168,7 @@ public async Task GetKeysInGroup_ShouldReturnEmptyList_WhenGroupHasNoKeys() } [Fact] - public async Task GetKeysInGroup_ShouldReturnInternalServerError_WhenExceptionOccurs() + public async Task GetKeysInGroup_ShouldReturnBadRequest_WhenInvalidOperationExceptionOccurs() { // Arrange var groupId = 1; @@ -186,19 +178,12 @@ public async Task GetKeysInGroup_ShouldReturnInternalServerError_WhenExceptionOc // Act var result = await _controller.GetKeysInGroup(groupId); - // Assert - var actionResult = Assert.IsType>>(result); - var statusCodeResult = Assert.IsType(actionResult.Result); - - Assert.Equal(500, statusCodeResult.StatusCode); - - var response = statusCodeResult.Value; - Assert.NotNull(response); - - // Use reflection to check the message property - var messageProperty = response.GetType().GetProperty("message"); - Assert.NotNull(messageProperty); - Assert.Equal("An error occurred while retrieving the keys", messageProperty.GetValue(response)); + // Assert - ExceptionToResponseMapper maps InvalidOperationException to 400 Bad Request + var badRequestResult = result.Should().BeOfType().Subject; + + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + Assert.Equal("The requested operation is not valid", errorResponse.error); + Assert.Equal("invalid_operation", errorResponse.Code); // Verify the repository method was called _mockGroupRepository.Verify(r => r.GetByIdWithKeysAsync(groupId), Times.Once); @@ -227,10 +212,9 @@ public async Task GetKeysInGroup_ShouldHandleNullVirtualKeysCollection() // Act var result = await _controller.GetKeysInGroup(groupId); - // Assert - var actionResult = Assert.IsType>>(result); - var okResult = Assert.IsType(actionResult.Result); - var keys = Assert.IsType>(okResult.Value); + // Assert - Controller returns IActionResult, not ActionResult + var okResult = result.Should().BeOfType().Subject; + var keys = okResult.Value.Should().BeOfType>().Subject; Assert.Empty(keys); diff --git a/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeysControllerTests.cs b/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeysControllerTests.cs index 3d129e333..6bcdcf24f 100644 --- a/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeysControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Controllers/VirtualKeysControllerTests.cs @@ -4,6 +4,8 @@ using ConduitLLM.Admin.Interfaces; using ConduitLLM.Configuration.DTOs.VirtualKey; +using FluentAssertions; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -81,14 +83,14 @@ public async Task GenerateKey_ValidRequest_ReturnsCreatedResult() var result = await _controller.GenerateKey(request); // Assert - var createdResult = Assert.IsType(result); - var response = Assert.IsType(createdResult.Value); + var createdResult = result.Should().BeOfType().Subject; + var response = createdResult.Value.Should().BeOfType().Subject; Assert.Equal("vk_test123", response.VirtualKey); Assert.Equal(1, response.KeyInfo.VirtualKeyGroupId); } [Fact] - public async Task GenerateKey_ServiceThrowsInvalidOperation_ReturnsInternalServerError() + public async Task GenerateKey_ServiceThrowsInvalidOperation_ReturnsBadRequest() { // Arrange var request = new CreateVirtualKeyRequestDto @@ -103,12 +105,8 @@ public async Task GenerateKey_ServiceThrowsInvalidOperation_ReturnsInternalServe // Act var result = await _controller.GenerateKey(request); - // Assert - var statusCodeResult = Assert.IsType(result); - Assert.Equal(StatusCodes.Status500InternalServerError, statusCodeResult.StatusCode); - - // In a real scenario, you might want to return a more specific error code (e.g., 404) - // for "group not found" scenarios by catching specific exceptions + // Assert - AdminControllerBase maps InvalidOperationException to 400 Bad Request + result.Should().BeOfType(); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Admin/Extensions/RequestBodyCaptureTests.cs b/Tests/ConduitLLM.Tests/Admin/Extensions/RequestBodyCaptureTests.cs new file mode 100644 index 000000000..8d0e378e1 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Extensions/RequestBodyCaptureTests.cs @@ -0,0 +1,228 @@ +using System.Text; + +using ConduitLLM.Core.Extensions; + +using Microsoft.AspNetCore.Http; + +namespace ConduitLLM.Tests.Admin.Extensions +{ + [Trait("Category", "Unit")] + [Trait("Component", "Admin")] + public class RequestBodyCaptureTests + { + [Fact] + public async Task CaptureAsync_ReturnsNull_WhenContextIsNull() + { + var result = await RequestBodyCapture.CaptureAsync(null); + + Assert.Null(result); + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + [InlineData("OPTIONS")] + public async Task CaptureAsync_ReturnsNull_ForNonMutationMethods(string method) + { + var context = CreateHttpContext(method, """{"name": "test"}"""); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.Null(result); + } + + [Theory] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public async Task CaptureAsync_ReturnsBody_ForMutationMethods(string method) + { + var context = CreateHttpContext(method, """{"name": "test"}"""); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.Contains("test", result); + } + + [Fact] + public async Task CaptureAsync_ReturnsNull_WhenBodyIsEmpty() + { + var context = CreateHttpContext("POST", ""); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.Null(result); + } + + [Fact] + public async Task CaptureAsync_RedactsSensitiveFields_ApiKey() + { + var body = """{"name": "test", "apiKey": "sk-secret-123", "description": "hello"}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.DoesNotContain("sk-secret-123", result); + Assert.Contains("[REDACTED]", result); + Assert.Contains("test", result); + Assert.Contains("hello", result); + } + + [Fact] + public async Task CaptureAsync_RedactsSensitiveFields_Password() + { + var body = """{"username": "admin", "password": "super-secret"}"""; + var context = CreateHttpContext("PUT", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.DoesNotContain("super-secret", result); + Assert.Contains("[REDACTED]", result); + Assert.Contains("admin", result); + } + + [Fact] + public async Task CaptureAsync_RedactsSensitiveFields_Token() + { + var body = """{"token": "bearer-xyz-789", "data": "safe"}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.DoesNotContain("bearer-xyz-789", result); + Assert.Contains("[REDACTED]", result); + Assert.Contains("safe", result); + } + + [Fact] + public async Task CaptureAsync_RedactsSensitiveFields_Secret() + { + var body = """{"clientSecret": "abc123", "name": "test"}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.DoesNotContain("abc123", result); + Assert.Contains("[REDACTED]", result); + } + + [Fact] + public async Task CaptureAsync_RedactsSensitiveFields_Credential() + { + var body = """{"credentialValue": "my-cred", "purpose": "testing"}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.DoesNotContain("my-cred", result); + Assert.Contains("[REDACTED]", result); + } + + [Fact] + public async Task CaptureAsync_PreservesNonSensitiveFields() + { + var body = """{"name": "Model A", "description": "A test model", "costPerToken": 0.01}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.Contains("Model A", result); + Assert.Contains("A test model", result); + Assert.Contains("0.01", result); + } + + [Fact] + public async Task CaptureAsync_TruncatesLargeBody() + { + // Create a body larger than 4096 characters + var largeValue = new string('x', 5000); + var body = $$"""{"data": "{{largeValue}}"}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + // Result is bounded by both our 4096 truncation and LoggingSanitizer's 1000-char limit + Assert.True(result.Length <= 1000, $"Expected max 1000 chars, got {result.Length}"); + Assert.True(result.Length < body.Length); + } + + [Fact] + public async Task CaptureAsync_HandlesMultipleSensitiveFields() + { + var body = """{"apiKey": "key1", "secret": "sec1", "authToken": "tok1", "name": "safe"}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.DoesNotContain("key1", result); + Assert.DoesNotContain("sec1", result); + Assert.DoesNotContain("tok1", result); + Assert.Contains("safe", result); + } + + [Fact] + public async Task CaptureAsync_HandlesNonJsonBody() + { + var body = "this is plain text, not json"; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.Contains("this is plain text", result); + } + + [Fact] + public async Task CaptureAsync_BodyCanBeReReadAfterCapture() + { + var originalBody = """{"name": "test"}"""; + var context = CreateHttpContext("POST", originalBody); + + await RequestBodyCapture.CaptureAsync(context); + + // Verify body stream is still readable + context.Request.Body.Position = 0; + using var reader = new StreamReader(context.Request.Body); + var rereadBody = await reader.ReadToEndAsync(); + + Assert.Equal(originalBody, rereadBody); + } + + [Fact] + public async Task CaptureAsync_CaseInsensitive_SensitiveFieldNames() + { + var body = """{"APIKEY": "val1", "Password": "val2", "AUTH_TOKEN": "val3"}"""; + var context = CreateHttpContext("POST", body); + + var result = await RequestBodyCapture.CaptureAsync(context); + + Assert.NotNull(result); + Assert.DoesNotContain("val1", result); + Assert.DoesNotContain("val2", result); + Assert.DoesNotContain("val3", result); + } + + private static HttpContext CreateHttpContext(string method, string body) + { + var context = new DefaultHttpContext(); + context.Request.Method = method; + + var bodyBytes = Encoding.UTF8.GetBytes(body); + context.Request.Body = new MemoryStream(bodyBytes); + context.Request.ContentLength = bodyBytes.Length; + context.Request.ContentType = "application/json"; + + return context; + } + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Create.cs b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Create.cs index ea630239e..95ebdcf8e 100644 --- a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Create.cs +++ b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Create.cs @@ -46,9 +46,9 @@ public async Task CreateModelCost_WithAssociations_ShouldCreateAndAssociate() var result = await _controller.CreateModelCost(createDto); // Assert - var createdResult = Assert.IsType(result); - var createdCost = Assert.IsType(createdResult.Value); - + var createdResult = result.Should().BeOfType().Subject; + var createdCost = createdResult.Value.Should().BeOfType().Subject; + createdCost.CostName.Should().Be("GPT-4 Pricing"); createdCost.AssociatedModelAliases.Should().HaveCount(2); createdCost.AssociatedModelAliases.Should().Contain(new[] { "gpt-4", "gpt-3.5-turbo" }); @@ -86,8 +86,10 @@ public async Task CreateModelCost_DuplicateName_ShouldReturnBadRequest() var result = await _controller.CreateModelCost(duplicateCost); // Assert - var badRequestResult = Assert.IsType(result); - badRequestResult.Value.Should().Be("A model cost with name 'Standard Pricing' already exists"); + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + errorResponse.error.Should().Be("The requested operation is not valid"); + errorResponse.Code.Should().Be("invalid_operation"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Delete.cs b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Delete.cs index c15d78934..dce1f95a8 100644 --- a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Delete.cs +++ b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Delete.cs @@ -1,5 +1,6 @@ using ConduitLLM.Configuration; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Extensions; using FluentAssertions; @@ -19,7 +20,8 @@ public async Task DeleteModelCost_WithMappings_ShouldRemoveAll() { // Arrange var providerId = await SetupTestDataAsync(); - var mappings = await _modelMappingRepository.GetAllAsync(); + var mappings = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelMappingRepository.GetPaginatedAsync); var mappingIds = mappings.Select(m => m.Id).ToList(); var createDto = new CreateModelCostDto @@ -31,14 +33,14 @@ public async Task DeleteModelCost_WithMappings_ShouldRemoveAll() }; var createResult = await _controller.CreateModelCost(createDto); - var createdResult = Assert.IsType(createResult); - var createdCost = Assert.IsType(createdResult.Value); + var createdResult = createResult.Should().BeOfType().Subject; + var createdCost = createdResult.Value.Should().BeOfType().Subject; // Act var deleteResult = await _controller.DeleteModelCost(createdCost.Id); // Assert - Assert.IsType(deleteResult); + deleteResult.Should().BeOfType(); // Verify cost deleted var deletedCost = await _modelCostRepository.GetByIdAsync(createdCost.Id); diff --git a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.EdgeCases.cs b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.EdgeCases.cs index 40d14bd1f..f65f31d1a 100644 --- a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.EdgeCases.cs +++ b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.EdgeCases.cs @@ -35,9 +35,9 @@ public async Task CreateModelCost_WithInvalidMappingIds_ShouldStillCreate() var result = await _controller.CreateModelCost(createDto); // Assert - var createdResult = Assert.IsType(result); - var createdCost = Assert.IsType(createdResult.Value); - + var createdResult = result.Should().BeOfType().Subject; + var createdCost = createdResult.Value.Should().BeOfType().Subject; + createdCost.CostName.Should().Be("Cost with Invalid Mappings"); createdCost.AssociatedModelAliases.Should().BeEmpty(); // No valid mappings @@ -67,8 +67,8 @@ public async Task UpdateModelCost_ConcurrentUpdates_LastWriteWins() }; var createResult = await _controller.CreateModelCost(createDto); - var createdResult = Assert.IsType(createResult); - var createdCost = Assert.IsType(createdResult.Value); + var createdResult = createResult.Should().BeOfType().Subject; + var createdCost = createdResult.Value.Should().BeOfType().Subject; // Prepare two concurrent updates var update1 = new UpdateModelCostDto diff --git a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Get.cs b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Get.cs index 6dd38af94..125500f7a 100644 --- a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Get.cs +++ b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Get.cs @@ -45,15 +45,15 @@ public async Task GetModelCostById_WithMappings_ShouldReturnAssociatedAliases() }; var createResult = await _controller.CreateModelCost(createDto); - var createdResult = Assert.IsType(createResult); - var createdCost = Assert.IsType(createdResult.Value); + var createdResult = createResult.Should().BeOfType().Subject; + var createdCost = createdResult.Value.Should().BeOfType().Subject; // Act var getResult = await _controller.GetModelCostById(createdCost.Id); // Assert - var okResult = Assert.IsType(getResult); - var retrievedCost = Assert.IsType(okResult.Value); + var okResult = getResult.Should().BeOfType().Subject; + var retrievedCost = okResult.Value.Should().BeOfType().Subject; retrievedCost.CostName.Should().Be("Test Cost with Associations"); retrievedCost.AssociatedModelAliases.Should().HaveCount(3); @@ -106,8 +106,8 @@ public async Task GetAllModelCosts_ShouldReturnAllWithAssociations() var result = await _controller.GetAllModelCosts(); // Assert - var okResult = Assert.IsType(result); - var costs = Assert.IsAssignableFrom>(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var costs = okResult.Value.Should().BeAssignableTo>().Subject; var costList = costs.ToList(); costList.Should().HaveCount(2); diff --git a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Update.cs b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Update.cs index e2c372169..81bf93917 100644 --- a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Update.cs +++ b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.Update.cs @@ -1,6 +1,7 @@ using ConduitLLM.Configuration; using ConduitLLM.Configuration.DTOs; using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Extensions; using FluentAssertions; @@ -47,8 +48,8 @@ public async Task UpdateModelCost_ChangeMappings_ShouldUpdateCorrectly() }; var createResult = await _controller.CreateModelCost(createDto); - var createdResult = Assert.IsType(createResult); - var createdCost = Assert.IsType(createdResult.Value); + var createdResult = createResult.Should().BeOfType().Subject; + var createdCost = createdResult.Value.Should().BeOfType().Subject; // Update with different mappings var updateDto = new UpdateModelCostDto @@ -64,7 +65,7 @@ public async Task UpdateModelCost_ChangeMappings_ShouldUpdateCorrectly() var updateResult = await _controller.UpdateModelCost(createdCost.Id, updateDto); // Assert - Assert.IsType(updateResult); + updateResult.Should().BeOfType(); // Verify updated mappings var updatedCost = await _modelCostRepository.GetByIdAsync(createdCost.Id); @@ -81,7 +82,8 @@ public async Task UpdateModelCost_RemoveAllMappings_ShouldClearAssociations() { // Arrange await SetupTestDataAsync(); - var mappings = await _modelMappingRepository.GetAllAsync(); + var mappings = await RepositoryPaginationExtensions.GetAllViaPaginationAsync( + _modelMappingRepository.GetPaginatedAsync); var mappingIds = mappings.Select(m => m.Id).ToList(); // Create cost with mappings @@ -94,8 +96,8 @@ public async Task UpdateModelCost_RemoveAllMappings_ShouldClearAssociations() }; var createResult = await _controller.CreateModelCost(createDto); - var createdResult = Assert.IsType(createResult); - var createdCost = Assert.IsType(createdResult.Value); + var createdResult = createResult.Should().BeOfType().Subject; + var createdCost = createdResult.Value.Should().BeOfType().Subject; // Update to remove all mappings var updateDto = new UpdateModelCostDto @@ -111,7 +113,7 @@ public async Task UpdateModelCost_RemoveAllMappings_ShouldClearAssociations() var updateResult = await _controller.UpdateModelCost(createdCost.Id, updateDto); // Assert - Assert.IsType(updateResult); + updateResult.Should().BeOfType(); // Verify mappings removed using (var verifyContext = new ConduitDbContext(_dbContextOptions)) diff --git a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.cs b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.cs index be10c90d2..0e6497493 100644 --- a/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Integration/ModelCostIntegrationTests.cs @@ -72,8 +72,8 @@ public ModelCostIntegrationTests() _modelCostRepository, _requestLogRepository, mockDbContextFactory.Object, - _mockPublishEndpoint.Object, - _mockServiceLogger.Object); + _mockServiceLogger.Object, + _mockPublishEndpoint.Object); // Create controller with real service _controller = new ModelCostsController(_service, _mockPricingValidator.Object, _mockControllerLogger.Object); diff --git a/Tests/ConduitLLM.Tests/Admin/Middleware/AdminExceptionMiddlewareTests.cs b/Tests/ConduitLLM.Tests/Admin/Middleware/AdminExceptionMiddlewareTests.cs new file mode 100644 index 000000000..f7eb35736 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Middleware/AdminExceptionMiddlewareTests.cs @@ -0,0 +1,379 @@ +using System.Text.Json; + +using ConduitLLM.Admin.Middleware; +using ConduitLLM.Core.Exceptions; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Moq; + +namespace ConduitLLM.Tests.Admin.Middleware +{ + [Trait("Category", "Unit")] + [Trait("Component", "Admin")] + public class AdminExceptionMiddlewareTests + { + private readonly Mock _mockNext; + private readonly Mock> _mockLogger; + private readonly Mock _mockEnvironment; + private readonly AdminExceptionMiddleware _middleware; + private readonly DefaultHttpContext _httpContext; + + public AdminExceptionMiddlewareTests() + { + _mockNext = new Mock(); + _mockLogger = new Mock>(); + _mockEnvironment = new Mock(); + + _mockEnvironment.Setup(x => x.EnvironmentName).Returns(Environments.Production); + + _middleware = new AdminExceptionMiddleware( + _mockNext.Object, + _mockLogger.Object, + _mockEnvironment.Object); + + _httpContext = new DefaultHttpContext(); + _httpContext.Response.Body = new MemoryStream(); + _httpContext.TraceIdentifier = "test-trace-id"; + } + + [Fact] + public async Task ModelNotFoundException_Returns404() + { + // Arrange + var modelName = "gpt-5"; + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new ModelNotFoundException(modelName)); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(404, _httpContext.Response.StatusCode); + Assert.Equal("application/json", _httpContext.Response.ContentType); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Contains(modelName, error); + Assert.Equal("model_not_found", code); + } + + [Fact] + public async Task InvalidRequestException_Returns400() + { + // Arrange + var exception = new InvalidRequestException("Invalid parameter", "invalid_param", "test_field"); + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(exception); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(400, _httpContext.Response.StatusCode); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Equal("Invalid parameter", error); + Assert.Equal("invalid_param", code); + } + + [Fact] + public async Task AuthorizationException_Returns403() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new AuthorizationException("Access denied")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(403, _httpContext.Response.StatusCode); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Equal("Access denied", error); + Assert.Equal("forbidden", code); + } + + [Fact] + public async Task RequestTimeoutException_Returns408() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new RequestTimeoutException("Request timed out")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(408, _httpContext.Response.StatusCode); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Equal("Request timed out", error); + Assert.Equal("request_timeout", code); + } + + [Fact] + public async Task RateLimitException_Returns429_WithRetryAfterHeader() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new RateLimitExceededException("Rate limit exceeded", 60)); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(429, _httpContext.Response.StatusCode); + Assert.Equal("60", _httpContext.Response.Headers["Retry-After"]); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Equal("Rate limit exceeded", error); + Assert.Equal("rate_limit_exceeded", code); + } + + [Fact] + public async Task ServiceUnavailableException_Returns503() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new ServiceUnavailableException("Service unavailable", "TestService")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(503, _httpContext.Response.StatusCode); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Equal("Service unavailable", error); + Assert.Equal("service_unavailable", code); + } + + [Fact] + public async Task ArgumentNullException_Returns400() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new ArgumentNullException("param")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(400, _httpContext.Response.StatusCode); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Equal("Required parameter is missing", error); + Assert.Equal("missing_parameter", code); + } + + [Fact] + public async Task KeyNotFoundException_Returns404() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("Resource not found")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(404, _httpContext.Response.StatusCode); + + var (error, code) = GetErrorResponse(_httpContext); + + Assert.Equal("The requested resource was not found", error); + Assert.Equal("not_found", code); + } + + [Fact] + public async Task UnhandledException_Returns500_GenericMessage() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new Exception("secret details")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(500, _httpContext.Response.StatusCode); + + var (error, code) = GetErrorResponse(_httpContext); + + // In production, should not expose internal details + Assert.Equal("An unexpected error occurred", error); + Assert.Equal("internal_error", code); + } + + [Fact] + public async Task UnhandledException_InDevelopment_ShowsDetails() + { + // Arrange + _mockEnvironment.Setup(x => x.EnvironmentName).Returns(Environments.Development); + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new Exception("Detailed error message")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(500, _httpContext.Response.StatusCode); + + var (error, _) = GetErrorResponse(_httpContext); + + // In development, should show actual error message + Assert.Equal("Detailed error message", error); + } + + [Fact] + public async Task XRequestIdHeader_IsSet() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new Exception("test")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal("test-trace-id", _httpContext.Response.Headers["X-Request-Id"]); + } + + [Fact] + public async Task ResponseAlreadyStarted_DoesNotThrow() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new Exception("test")); + + // Use a mock response that reports HasStarted = true + var mockResponse = new Mock(); + mockResponse.Setup(r => r.HasStarted).Returns(true); + var mockContext = new Mock(); + mockContext.Setup(c => c.Response).Returns(mockResponse.Object); + mockContext.Setup(c => c.TraceIdentifier).Returns("test-trace-id"); + mockContext.Setup(c => c.Request.Method).Returns("GET"); + mockContext.Setup(c => c.Request.Path).Returns(new PathString("/test")); + + // Act & Assert - should not throw + await _middleware.InvokeAsync(mockContext.Object); + } + + [Fact] + public async Task NoException_PassesThrough() + { + // Arrange + var nextCalled = false; + _mockNext.Setup(x => x(It.IsAny())) + .Callback(_ => nextCalled = true) + .Returns(Task.CompletedTask); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.True(nextCalled); + Assert.Equal(200, _httpContext.Response.StatusCode); + } + + [Fact] + public async Task ContentType_IsJson() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new Exception("test")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal("application/json", _httpContext.Response.ContentType); + } + + [Fact] + public async Task LogsError_WithExceptionAndRequestDetails() + { + // Arrange + var exception = new InvalidOperationException("something broke"); + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(exception); + + _httpContext.Request.Method = "POST"; + _httpContext.Request.Path = "/api/providers"; + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert โ€” LogError was called with the exception + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("test-trace-id")), + exception, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ResponseAlreadyStarted_LogsWarning() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new Exception("test")); + + var mockResponse = new Mock(); + mockResponse.Setup(r => r.HasStarted).Returns(true); + var mockContext = new Mock(); + mockContext.Setup(c => c.Response).Returns(mockResponse.Object); + mockContext.Setup(c => c.TraceIdentifier).Returns("started-trace-id"); + mockContext.Setup(c => c.Request.Method).Returns("GET"); + mockContext.Setup(c => c.Request.Path).Returns(new PathString("/test")); + + // Act + await _middleware.InvokeAsync(mockContext.Object); + + // Assert โ€” LogWarning was called about response already started + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("started-trace-id")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Deserializes the response body and extracts the error message and code. + /// ErrorResponseDto.error is object type, which deserializes as JsonElement. + /// + private static (string error, string? code) GetErrorResponse(HttpContext context) + { + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body); + var body = reader.ReadToEnd(); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + var error = root.GetProperty("error").GetString() ?? string.Empty; + var code = root.TryGetProperty("code", out var codeElement) + ? codeElement.GetString() + : null; + + return (error, code); + } + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Security/MasterKeyAuthenticationHandlerTests.cs b/Tests/ConduitLLM.Tests/Admin/Security/MasterKeyAuthenticationHandlerTests.cs new file mode 100644 index 000000000..63bce0a48 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Security/MasterKeyAuthenticationHandlerTests.cs @@ -0,0 +1,205 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using ConduitLLM.Admin.Security; +using ConduitLLM.Admin.Services; + +namespace ConduitLLM.Tests.Admin.Security +{ + [Trait("Category", "Unit")] + [Trait("Component", "Security")] + public class MasterKeyAuthenticationHandlerTests : IDisposable + { + private readonly Mock _ephemeralKeyServiceMock; + private readonly Mock _configurationMock; + private readonly Mock _loggerFactoryMock; + private readonly Mock> _loggerMock; + + public MasterKeyAuthenticationHandlerTests() + { + _ephemeralKeyServiceMock = new Mock(); + _configurationMock = new Mock(); + _loggerFactoryMock = new Mock(); + _loggerMock = new Mock>(); + _loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny())).Returns(_loggerMock.Object); + } + + public void Dispose() + { + // Clean up environment variable after each test + Environment.SetEnvironmentVariable("CONDUIT_API_TO_API_BACKEND_AUTH_KEY", null); + } + + private async Task RunAuthenticationAsync( + string? masterKey, + Action? configureContext = null) + { + Environment.SetEnvironmentVariable("CONDUIT_API_TO_API_BACKEND_AUTH_KEY", masterKey); + + var options = new MasterKeyAuthenticationSchemeOptions(); + var optionsMonitor = new Mock>(); + optionsMonitor.Setup(o => o.Get(It.IsAny())).Returns(options); + optionsMonitor.Setup(o => o.CurrentValue).Returns(options); + + var scheme = new AuthenticationScheme("MasterKey", "MasterKey", typeof(MasterKeyAuthenticationHandler)); + + var handler = new MasterKeyAuthenticationHandler( + optionsMonitor.Object, + _loggerFactoryMock.Object, + UrlEncoder.Default, + _configurationMock.Object, + _ephemeralKeyServiceMock.Object); + + var httpContext = new DefaultHttpContext(); + configureContext?.Invoke(httpContext); + + await handler.InitializeAsync(scheme, httpContext); + return await handler.AuthenticateAsync(); + } + + [Fact] + public async Task HandleAuthenticateAsync_HealthCheckPath_SucceedsWithoutKey() + { + var result = await RunAuthenticationAsync(null, ctx => + { + ctx.Request.Path = "/health/live"; + }); + + Assert.True(result.Succeeded); + Assert.Equal("HealthCheck", result.Principal?.Identity?.Name); + } + + [Fact] + public async Task HandleAuthenticateAsync_MissingKey_LogsWarningAndFails() + { + var result = await RunAuthenticationAsync("configured-key", ctx => + { + ctx.Request.Path = "/api/test"; + // No key headers set + }); + + Assert.True(result.None || !result.Succeeded); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("no API key provided")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task HandleAuthenticateAsync_InvalidKey_LogsWarningWithClientIp() + { + var result = await RunAuthenticationAsync("correct-key", ctx => + { + ctx.Request.Path = "/api/test"; + ctx.Request.Headers["X-API-Key"] = "wrong-key"; + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1"); + }); + + Assert.False(result.Succeeded); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("invalid master key provided")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task HandleAuthenticateAsync_ValidKey_SucceedsWithAdminClaims() + { + var result = await RunAuthenticationAsync("valid-key", ctx => + { + ctx.Request.Path = "/api/test"; + ctx.Request.Headers["X-API-Key"] = "valid-key"; + }); + + Assert.True(result.Succeeded); + Assert.Equal("AdminUser", result.Principal?.Identity?.Name); + Assert.True(result.Principal?.HasClaim("MasterKey", "true")); + } + + [Fact] + public async Task HandleAuthenticateAsync_MasterKeyNotConfigured_LogsError() + { + var result = await RunAuthenticationAsync(null, ctx => + { + ctx.Request.Path = "/api/test"; + ctx.Request.Headers["X-API-Key"] = "some-key"; + }); + + Assert.False(result.Succeeded); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Backend auth key is not configured")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task HandleAuthenticateAsync_ValidEphemeralKey_SucceedsAndLogs() + { + _ephemeralKeyServiceMock.Setup(s => s.ValidateAndConsumeKeyAsync("emk_valid123")) + .ReturnsAsync(true); + + var result = await RunAuthenticationAsync("master-key", ctx => + { + ctx.Request.Path = "/api/test"; + ctx.Request.Headers["X-API-Key"] = "emk_valid123"; + }); + + Assert.True(result.Succeeded); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Authenticated via ephemeral master key")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task HandleAuthenticateAsync_ExpiredEphemeralKey_LogsWarningAndFails() + { + _ephemeralKeyServiceMock.Setup(s => s.ValidateAndConsumeKeyAsync("emk_expired123")) + .ReturnsAsync(false); + _ephemeralKeyServiceMock.Setup(s => s.KeyExistsAsync("emk_expired123")) + .ReturnsAsync(true); + + var result = await RunAuthenticationAsync("master-key", ctx => + { + ctx.Request.Path = "/api/test"; + ctx.Request.Headers["X-API-Key"] = "emk_expired123"; + }); + + Assert.False(result.Succeeded); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Ephemeral master key validation failed")), + null, + It.IsAny>()), + Times.Once); + } + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminGlobalSettingServiceTests.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminGlobalSettingServiceTests.cs new file mode 100644 index 000000000..75088ce14 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminGlobalSettingServiceTests.cs @@ -0,0 +1,256 @@ +using ConduitLLM.Admin.Services; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; +using FluentAssertions; +using MassTransit; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ConduitLLM.Tests.Admin.Services; + +public class AdminGlobalSettingServiceTests +{ + private readonly Mock _mockGlobalSettingRepository; + private readonly Mock _mockPublishEndpoint; + private readonly Mock> _mockLogger; + private readonly AdminGlobalSettingService _service; + + public AdminGlobalSettingServiceTests() + { + _mockGlobalSettingRepository = new Mock(); + _mockPublishEndpoint = new Mock(); + _mockLogger = new Mock>(); + + _service = new AdminGlobalSettingService( + _mockGlobalSettingRepository.Object, + _mockLogger.Object, + _mockPublishEndpoint.Object); + } + + [Fact] + public async Task GetAllSettingsAsync_ShouldReturnMappedDtos() + { + // Arrange + var entities = new List + { + new() { Id = 1, Key = "setting1", Value = "value1", Description = "desc1" }, + new() { Id = 2, Key = "setting2", Value = "value2", Description = null } + }; + _mockGlobalSettingRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())) + .ReturnsAsync(entities); + + // Act + var result = (await _service.GetAllSettingsAsync()).ToList(); + + // Assert + result.Should().HaveCount(2); + result[0].Key.Should().Be("setting1"); + result[0].Value.Should().Be("value1"); + result[1].Key.Should().Be("setting2"); + } + + [Fact] + public async Task GetSettingByIdAsync_WithExistingId_ShouldReturnDto() + { + // Arrange + var entity = new GlobalSetting { Id = 1, Key = "test-key", Value = "test-value" }; + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(entity); + + // Act + var result = await _service.GetSettingByIdAsync(1); + + // Assert + result.Should().NotBeNull(); + result!.Key.Should().Be("test-key"); + result.Value.Should().Be("test-value"); + } + + [Fact] + public async Task GetSettingByIdAsync_WithNonExistentId_ShouldReturnNull() + { + // Arrange + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((GlobalSetting?)null); + + // Act + var result = await _service.GetSettingByIdAsync(999); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetSettingByKeyAsync_WithExistingKey_ShouldReturnDto() + { + // Arrange + var entity = new GlobalSetting { Id = 1, Key = "my-key", Value = "my-value" }; + _mockGlobalSettingRepository.Setup(x => x.GetByKeyAsync("my-key", It.IsAny())) + .ReturnsAsync(entity); + + // Act + var result = await _service.GetSettingByKeyAsync("my-key"); + + // Assert + result.Should().NotBeNull(); + result!.Value.Should().Be("my-value"); + } + + [Fact] + public async Task CreateSettingAsync_WithUniqueKey_ShouldCreateAndReturnDto() + { + // Arrange + var createDto = new CreateGlobalSettingDto { Key = "new-key", Value = "new-value", Description = "desc" }; + var createdEntity = new GlobalSetting { Id = 1, Key = "new-key", Value = "new-value", Description = "desc" }; + + _mockGlobalSettingRepository.Setup(x => x.GetByKeyAsync("new-key", It.IsAny())) + .ReturnsAsync((GlobalSetting?)null); + _mockGlobalSettingRepository.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(createdEntity); + + // Act + var result = await _service.CreateSettingAsync(createDto); + + // Assert + result.Should().NotBeNull(); + result.Key.Should().Be("new-key"); + result.Value.Should().Be("new-value"); + } + + [Fact] + public async Task CreateSettingAsync_WithDuplicateKey_ShouldThrowInvalidOperationException() + { + // Arrange + var existing = new GlobalSetting { Id = 1, Key = "existing-key", Value = "old-value" }; + _mockGlobalSettingRepository.Setup(x => x.GetByKeyAsync("existing-key", It.IsAny())) + .ReturnsAsync(existing); + + var createDto = new CreateGlobalSettingDto { Key = "existing-key", Value = "new-value" }; + + // Act + var act = () => _service.CreateSettingAsync(createDto); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*already exists*"); + } + + [Fact] + public async Task UpdateSettingAsync_WithExistingId_ShouldUpdateAndReturnTrue() + { + // Arrange + var existing = new GlobalSetting { Id = 1, Key = "key", Value = "old-value", Description = "old-desc" }; + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(existing); + _mockGlobalSettingRepository.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var updateDto = new UpdateGlobalSettingDto { Id = 1, Value = "new-value", Description = "new-desc" }; + + // Act + var result = await _service.UpdateSettingAsync(updateDto); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task UpdateSettingAsync_WithNonExistentId_ShouldReturnFalse() + { + // Arrange + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((GlobalSetting?)null); + + var updateDto = new UpdateGlobalSettingDto { Id = 999, Value = "new-value" }; + + // Act + var result = await _service.UpdateSettingAsync(updateDto); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task UpdateSettingAsync_WithNoChanges_ShouldReturnTrueWithoutCallingUpdate() + { + // Arrange + var existing = new GlobalSetting { Id = 1, Key = "key", Value = "same-value", Description = "same-desc" }; + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(existing); + + var updateDto = new UpdateGlobalSettingDto { Id = 1, Value = "same-value", Description = "same-desc" }; + + // Act + var result = await _service.UpdateSettingAsync(updateDto); + + // Assert + result.Should().BeTrue(); + _mockGlobalSettingRepository.Verify( + x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteSettingAsync_WithExistingId_ShouldDeleteAndReturnTrue() + { + // Arrange + var entity = new GlobalSetting { Id = 1, Key = "to-delete", Value = "val" }; + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(entity); + _mockGlobalSettingRepository.Setup(x => x.DeleteAsync(1, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.DeleteSettingAsync(1); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task DeleteSettingAsync_WithNonExistentId_ShouldReturnFalse() + { + // Arrange + _mockGlobalSettingRepository.Setup(x => x.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((GlobalSetting?)null); + + // Act + var result = await _service.DeleteSettingAsync(999); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task DeleteSettingByKeyAsync_WithExistingKey_ShouldDeleteAndReturnTrue() + { + // Arrange + var entity = new GlobalSetting { Id = 1, Key = "to-delete", Value = "val" }; + _mockGlobalSettingRepository.Setup(x => x.GetByKeyAsync("to-delete", It.IsAny())) + .ReturnsAsync(entity); + _mockGlobalSettingRepository.Setup(x => x.DeleteByKeyAsync("to-delete", It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.DeleteSettingByKeyAsync("to-delete"); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task DeleteSettingByKeyAsync_WithNonExistentKey_ShouldReturnFalse() + { + // Arrange + _mockGlobalSettingRepository.Setup(x => x.GetByKeyAsync("missing", It.IsAny())) + .ReturnsAsync((GlobalSetting?)null); + + // Act + var result = await _service.DeleteSettingByKeyAsync("missing"); + + // Assert + result.Should().BeFalse(); + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminIpFilterServiceTests.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminIpFilterServiceTests.cs new file mode 100644 index 000000000..1cbcf03d8 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminIpFilterServiceTests.cs @@ -0,0 +1,467 @@ +using ConduitLLM.Admin.Services; +using ConduitLLM.Configuration.Constants; +using ConduitLLM.Configuration.DTOs.IpFilter; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Configuration.Options; + +using FluentAssertions; + +using MassTransit; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Moq; + +using Xunit.Abstractions; + +namespace ConduitLLM.Tests.Admin.Services +{ + [Trait("Category", "Unit")] + [Trait("Component", "Service")] + public class AdminIpFilterServiceTests + { + private readonly Mock _mockIpFilterRepo; + private readonly Mock _mockGlobalSettingRepo; + private readonly Mock> _mockOptions; + private readonly Mock> _mockLogger; + private readonly Mock _mockPublishEndpoint; + private readonly AdminIpFilterService _service; + private readonly ITestOutputHelper _output; + + public AdminIpFilterServiceTests(ITestOutputHelper output) + { + _output = output; + _mockIpFilterRepo = new Mock(); + _mockGlobalSettingRepo = new Mock(); + _mockOptions = new Mock>(); + _mockLogger = new Mock>(); + _mockPublishEndpoint = new Mock(); + + _mockOptions.Setup(o => o.CurrentValue).Returns(new IpFilterOptions + { + Enabled = false, + DefaultAllow = true, + BypassForAdminUi = true, + ExcludedEndpoints = new List { "/api/v1/health" } + }); + + _service = new AdminIpFilterService( + _mockIpFilterRepo.Object, + _mockGlobalSettingRepo.Object, + _mockOptions.Object, + _mockLogger.Object, + _mockPublishEndpoint.Object); + } + + #region CreateFilterAsync Tests + + [Fact] + public async Task CreateFilterAsync_ValidFilter_LogsInformationAndReturnsSuccess() + { + // Arrange + var createDto = new CreateIpFilterDto + { + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + Description = "Test block", + IsEnabled = true + }; + + var createdEntity = new IpFilterEntity + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + Description = "Test block", + IsEnabled = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _mockIpFilterRepo.Setup(r => r.AddAsync(It.IsAny(), default)) + .ReturnsAsync(createdEntity); + + // Act + var (success, error, filter) = await _service.CreateFilterAsync(createDto); + + // Assert + success.Should().BeTrue(); + error.Should().BeNull(); + filter.Should().NotBeNull(); + filter!.Id.Should().Be(1); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("IP filter created")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task CreateFilterAsync_InvalidIp_ReturnsFailure() + { + // Arrange + var createDto = new CreateIpFilterDto + { + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "not-an-ip", + IsEnabled = true + }; + + // Act + var (success, error, filter) = await _service.CreateFilterAsync(createDto); + + // Assert + success.Should().BeFalse(); + error.Should().Contain("Invalid IP address"); + filter.Should().BeNull(); + } + + [Fact] + public async Task CreateFilterAsync_RepositoryThrows_LogsErrorAndReturnsFailure() + { + // Arrange + var createDto = new CreateIpFilterDto + { + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + IsEnabled = true + }; + + _mockIpFilterRepo.Setup(r => r.AddAsync(It.IsAny(), default)) + .ThrowsAsync(new Exception("Database error")); + + // Act + var (success, error, filter) = await _service.CreateFilterAsync(createDto); + + // Assert + success.Should().BeFalse(); + error.Should().Be("An unexpected error occurred"); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Error creating IP filter")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region UpdateFilterAsync Tests + + [Fact] + public async Task UpdateFilterAsync_WithChanges_LogsInformationAndReturnsSuccess() + { + // Arrange + var existingEntity = new IpFilterEntity + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + Description = "Old desc", + IsEnabled = true + }; + + _mockIpFilterRepo.Setup(r => r.GetByIdAsync(1, default)) + .ReturnsAsync(existingEntity); + _mockIpFilterRepo.Setup(r => r.UpdateAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + var updateDto = new UpdateIpFilterDto + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + Description = "New desc", + IsEnabled = false + }; + + // Act + var (success, error) = await _service.UpdateFilterAsync(updateDto); + + // Assert + success.Should().BeTrue(); + error.Should().BeNull(); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("IP filter updated")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task UpdateFilterAsync_NoChanges_SkipsUpdateAndDoesNotLogInfo() + { + // Arrange + var existingEntity = new IpFilterEntity + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + Description = "Same desc", + IsEnabled = true + }; + + _mockIpFilterRepo.Setup(r => r.GetByIdAsync(1, default)) + .ReturnsAsync(existingEntity); + + var updateDto = new UpdateIpFilterDto + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + Description = "Same desc", + IsEnabled = true + }; + + // Act + var (success, error) = await _service.UpdateFilterAsync(updateDto); + + // Assert + success.Should().BeTrue(); + _mockIpFilterRepo.Verify(r => r.UpdateAsync(It.IsAny(), default), Times.Never); + } + + [Fact] + public async Task UpdateFilterAsync_NotFound_ReturnsFailure() + { + // Arrange + _mockIpFilterRepo.Setup(r => r.GetByIdAsync(99, default)) + .ReturnsAsync((IpFilterEntity)null!); + + var updateDto = new UpdateIpFilterDto + { + Id = 99, + IpAddressOrCidr = "10.0.0.1" + }; + + // Act + var (success, error) = await _service.UpdateFilterAsync(updateDto); + + // Assert + success.Should().BeFalse(); + error.Should().Contain("not found"); + } + + [Fact] + public async Task UpdateFilterAsync_RepositoryFails_LogsWarningAndReturnsFailure() + { + // Arrange + var existingEntity = new IpFilterEntity + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1", + IsEnabled = true + }; + + _mockIpFilterRepo.Setup(r => r.GetByIdAsync(1, default)) + .ReturnsAsync(existingEntity); + _mockIpFilterRepo.Setup(r => r.UpdateAsync(It.IsAny(), default)) + .ReturnsAsync(false); + + var updateDto = new UpdateIpFilterDto + { + Id = 1, + FilterType = IpFilterConstants.WHITELIST, // Changed + IpAddressOrCidr = "10.0.0.1", + IsEnabled = true + }; + + // Act + var (success, error) = await _service.UpdateFilterAsync(updateDto); + + // Assert + success.Should().BeFalse(); + error.Should().Contain("Failed to update"); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Failed to update IP filter")), + null, + It.IsAny>()), + Times.Once); + } + + #endregion + + #region DeleteFilterAsync Tests + + [Fact] + public async Task DeleteFilterAsync_ExistingFilter_LogsInformationAndReturnsSuccess() + { + // Arrange + var existingEntity = new IpFilterEntity + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1" + }; + + _mockIpFilterRepo.Setup(r => r.GetByIdAsync(1, default)) + .ReturnsAsync(existingEntity); + _mockIpFilterRepo.Setup(r => r.DeleteAsync(1, default)) + .ReturnsAsync(true); + + // Act + var (success, error) = await _service.DeleteFilterAsync(1); + + // Assert + success.Should().BeTrue(); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("IP filter deleted")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task DeleteFilterAsync_NotFound_ReturnsFailure() + { + // Arrange + _mockIpFilterRepo.Setup(r => r.GetByIdAsync(99, default)) + .ReturnsAsync((IpFilterEntity)null!); + + // Act + var (success, error) = await _service.DeleteFilterAsync(99); + + // Assert + success.Should().BeFalse(); + error.Should().Contain("not found"); + } + + [Fact] + public async Task DeleteFilterAsync_RepositoryFails_LogsWarningAndReturnsFailure() + { + // Arrange + var existingEntity = new IpFilterEntity + { + Id = 1, + FilterType = IpFilterConstants.BLACKLIST, + IpAddressOrCidr = "10.0.0.1" + }; + + _mockIpFilterRepo.Setup(r => r.GetByIdAsync(1, default)) + .ReturnsAsync(existingEntity); + _mockIpFilterRepo.Setup(r => r.DeleteAsync(1, default)) + .ReturnsAsync(false); + + // Act + var (success, error) = await _service.DeleteFilterAsync(1); + + // Assert + success.Should().BeFalse(); + error.Should().Contain("Failed to delete"); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Failed to delete IP filter")), + null, + It.IsAny>()), + Times.Once); + } + + #endregion + + #region UpdateIpFilterSettingsAsync Tests + + [Fact] + public async Task UpdateIpFilterSettingsAsync_ValidSettings_LogsInformationAndReturnsSuccess() + { + // Arrange + var settings = new IpFilterSettingsDto + { + IsEnabled = true, + DefaultAllow = false, + BypassForAdminUi = true, + ExcludedEndpoints = new List { "/health" } + }; + + // Act + var (success, error) = await _service.UpdateIpFilterSettingsAsync(settings); + + // Assert + success.Should().BeTrue(); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("IP filter settings updated successfully")), + null, + It.IsAny>()), + Times.Once); + + // Verify all settings were persisted + _mockGlobalSettingRepo.Verify(r => r.UpsertAsync( + "IpFilter:Enabled", "True", It.IsAny()), Times.Once); + _mockGlobalSettingRepo.Verify(r => r.UpsertAsync( + "IpFilter:DefaultAllow", "False", It.IsAny()), Times.Once); + } + + #endregion + + #region GetIpFilterSettingsAsync Tests + + [Fact] + public async Task GetIpFilterSettingsAsync_NoDbSettings_FallsBackToOptions() + { + // Arrange - all GetByKeyAsync return null (no DB settings) + _mockGlobalSettingRepo.Setup(r => r.GetByKeyAsync(It.IsAny(), default)) + .ReturnsAsync((GlobalSetting)null!); + + // Act + var settings = await _service.GetIpFilterSettingsAsync(); + + // Assert + settings.IsEnabled.Should().BeFalse(); // From options default + settings.DefaultAllow.Should().BeTrue(); + } + + [Fact] + public async Task GetIpFilterSettingsAsync_RepositoryThrows_ReturnsDefaults() + { + // Arrange + _mockGlobalSettingRepo.Setup(r => r.GetByKeyAsync(It.IsAny(), default)) + .ThrowsAsync(new Exception("DB error")); + + // Act + var settings = await _service.GetIpFilterSettingsAsync(); + + // Assert + settings.IsEnabled.Should().BeFalse(); + settings.DefaultAllow.Should().BeTrue(); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains("Error getting IP filter settings")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminModelCostServiceTests.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminModelCostServiceTests.cs index b29d178f9..34714848b 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AdminModelCostServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminModelCostServiceTests.cs @@ -42,8 +42,8 @@ public AdminModelCostServiceTests() _mockModelCostRepository.Object, _mockRequestLogRepository.Object, _mockDbContextFactory.Object, - _mockPublishEndpoint.Object, - _mockLogger.Object); + _mockLogger.Object, + _mockPublishEndpoint.Object); } public void Dispose() diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminNotificationServiceTests.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminNotificationServiceTests.cs new file mode 100644 index 000000000..16b6960cf --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminNotificationServiceTests.cs @@ -0,0 +1,232 @@ +using ConduitLLM.Admin.Services; +using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ConduitLLM.Tests.Admin.Services; + +public class AdminNotificationServiceTests +{ + private readonly Mock _mockNotificationRepository; + private readonly Mock _mockVirtualKeyRepository; + private readonly Mock> _mockLogger; + private readonly AdminNotificationService _service; + + public AdminNotificationServiceTests() + { + _mockNotificationRepository = new Mock(); + _mockVirtualKeyRepository = new Mock(); + _mockLogger = new Mock>(); + + _service = new AdminNotificationService( + _mockNotificationRepository.Object, + _mockVirtualKeyRepository.Object, + _mockLogger.Object); + } + + [Fact] + public async Task GetNotificationByIdAsync_WithExistingId_ShouldReturnDto() + { + // Arrange + var entity = new Notification + { + Id = 1, + VirtualKeyId = null, + Type = NotificationType.System, + Severity = NotificationSeverity.Info, + Message = "System notification", + IsRead = false, + CreatedAt = DateTime.UtcNow + }; + _mockNotificationRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(entity); + + // Act + var result = await _service.GetNotificationByIdAsync(1); + + // Assert + result.Should().NotBeNull(); + result!.Message.Should().Be("System notification"); + result.Type.Should().Be(NotificationType.System); + result.IsRead.Should().BeFalse(); + } + + [Fact] + public async Task GetNotificationByIdAsync_WithVirtualKey_ShouldIncludeKeyName() + { + // Arrange + var entity = new Notification + { + Id = 1, + VirtualKeyId = 42, + Type = NotificationType.BudgetWarning, + Severity = NotificationSeverity.Warning, + Message = "Budget exceeded", + IsRead = false, + CreatedAt = DateTime.UtcNow + }; + var virtualKey = new VirtualKey { Id = 42, KeyName = "Production Key" }; + + _mockNotificationRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(entity); + _mockVirtualKeyRepository.Setup(x => x.GetByIdAsync(42, It.IsAny())) + .ReturnsAsync(virtualKey); + + // Act + var result = await _service.GetNotificationByIdAsync(1); + + // Assert + result.Should().NotBeNull(); + result!.VirtualKeyName.Should().Be("Production Key"); + result.VirtualKeyId.Should().Be(42); + } + + [Fact] + public async Task GetNotificationByIdAsync_WithNonExistentId_ShouldReturnNull() + { + // Arrange + _mockNotificationRepository.Setup(x => x.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((Notification?)null); + + // Act + var result = await _service.GetNotificationByIdAsync(999); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task CreateNotificationAsync_WithValidData_ShouldCreateAndReturnDto() + { + // Arrange + var createDto = new CreateNotificationDto + { + VirtualKeyId = null, + Type = NotificationType.System, + Severity = NotificationSeverity.Info, + Message = "New notification" + }; + var createdEntity = new Notification + { + Id = 1, + VirtualKeyId = null, + Type = NotificationType.System, + Severity = NotificationSeverity.Info, + Message = "New notification", + IsRead = false, + CreatedAt = DateTime.UtcNow + }; + + _mockNotificationRepository.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + _mockNotificationRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(createdEntity); + + // Act + var result = await _service.CreateNotificationAsync(createDto); + + // Assert + result.Should().NotBeNull(); + result.Message.Should().Be("New notification"); + result.IsRead.Should().BeFalse(); + } + + [Fact] + public async Task CreateNotificationAsync_WithInvalidVirtualKeyId_ShouldThrowArgumentException() + { + // Arrange + var createDto = new CreateNotificationDto + { + VirtualKeyId = 999, + Type = NotificationType.BudgetWarning, + Severity = NotificationSeverity.Warning, + Message = "Warning" + }; + + _mockVirtualKeyRepository.Setup(x => x.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((VirtualKey?)null); + + // Act + var act = () => _service.CreateNotificationAsync(createDto); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*999*not found*"); + } + + [Fact] + public async Task UpdateNotificationAsync_WithExistingId_ShouldUpdateAndReturnTrue() + { + // Arrange + var existing = new Notification + { + Id = 1, + Type = NotificationType.System, + Severity = NotificationSeverity.Info, + Message = "Old message", + IsRead = false + }; + + _mockNotificationRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(existing); + _mockNotificationRepository.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var updateDto = new UpdateNotificationDto { Id = 1, IsRead = true, Message = "Updated message" }; + + // Act + var result = await _service.UpdateNotificationAsync(updateDto); + + // Assert + result.Should().BeTrue(); + existing.IsRead.Should().BeTrue(); + existing.Message.Should().Be("Updated message"); + } + + [Fact] + public async Task UpdateNotificationAsync_WithNonExistentId_ShouldReturnFalse() + { + // Arrange + _mockNotificationRepository.Setup(x => x.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((Notification?)null); + + var updateDto = new UpdateNotificationDto { Id = 999, IsRead = true }; + + // Act + var result = await _service.UpdateNotificationAsync(updateDto); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task MarkNotificationAsReadAsync_ShouldDelegateToRepository() + { + // Arrange + _mockNotificationRepository.Setup(x => x.MarkAsReadAsync(1, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.MarkNotificationAsReadAsync(1); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task DeleteNotificationAsync_ShouldDelegateToRepository() + { + // Arrange + _mockNotificationRepository.Setup(x => x.DeleteAsync(1, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.DeleteNotificationAsync(1); + + // Assert + result.Should().BeTrue(); + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Core.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Core.cs index 0f875d38d..23c51bdc2 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Core.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Core.cs @@ -2,9 +2,11 @@ using ConduitLLM.Configuration; using ConduitLLM.Configuration.Interfaces; using ConduitLLM.Core.Interfaces; +using ConduitLLM.Tests.TestInfrastructure; using MassTransit; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -12,7 +14,7 @@ namespace ConduitLLM.Tests.Admin.Services { - public partial class AdminVirtualKeyServiceTests + public partial class AdminVirtualKeyServiceTests : IDisposable { private readonly Mock _mockVirtualKeyRepository; private readonly Mock _mockSpendHistoryRepository; @@ -23,8 +25,11 @@ public partial class AdminVirtualKeyServiceTests private readonly Mock _mockMediaLifecycleService; private readonly Mock _mockModelProviderMappingRepository; private readonly Mock _mockModelCapabilityService; - private readonly Mock> _mockDbContextFactory; + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbContextOptions; + private readonly TestDbContextFactory _dbContextFactory; private readonly AdminVirtualKeyService _service; + private bool _disposed; public AdminVirtualKeyServiceTests() { @@ -37,19 +42,39 @@ public AdminVirtualKeyServiceTests() _mockMediaLifecycleService = new Mock(); _mockModelProviderMappingRepository = new Mock(); _mockModelCapabilityService = new Mock(); - _mockDbContextFactory = new Mock>(); + + // SQLite-backed factory so tests that hit ExecuteUpdateAsync (e.g. PerformMaintenanceAsync) + // run against a real relational provider. EF's InMemory provider does not support it. + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _dbContextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + using (var ctx = new TestConduitDbContext(_dbContextOptions)) + { + ctx.Database.EnsureCreated(); + } + _dbContextFactory = new TestDbContextFactory(_dbContextOptions); _service = new AdminVirtualKeyService( _mockVirtualKeyRepository.Object, _mockSpendHistoryRepository.Object, _mockGroupRepository.Object, - _mockCache.Object, - _mockPublishEndpoint.Object, _mockLogger.Object, _mockModelProviderMappingRepository.Object, _mockModelCapabilityService.Object, - _mockDbContextFactory.Object, + _dbContextFactory, + _mockCache.Object, + _mockPublishEndpoint.Object, _mockMediaLifecycleService.Object); } + + public void Dispose() + { + if (_disposed) return; + _connection.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } } } \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.GroupFilter.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.GroupFilter.cs index 4fbe3aee5..66647bc8b 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.GroupFilter.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.GroupFilter.cs @@ -20,8 +20,9 @@ public async Task ListVirtualKeysAsync_WithoutGroupId_ReturnsAllKeys() new VirtualKey { Id = 3, KeyName = "Key3", VirtualKeyGroupId = 1 } }; - _mockVirtualKeyRepository.Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(allKeys); + _mockVirtualKeyRepository.Setup(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((allKeys, allKeys.Count)); // Act var result = await _service.ListVirtualKeysAsync(); @@ -29,8 +30,10 @@ public async Task ListVirtualKeysAsync_WithoutGroupId_ReturnsAllKeys() // Assert Assert.NotNull(result); Assert.Equal(3, result.Count); - _mockVirtualKeyRepository.Verify(x => x.GetAllAsync(It.IsAny()), Times.Once); - _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockVirtualKeyRepository.Verify(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.AtLeastOnce); + _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -44,8 +47,9 @@ public async Task ListVirtualKeysAsync_WithGroupId_ReturnsFilteredKeys() new VirtualKey { Id = 3, KeyName = "Key3", VirtualKeyGroupId = groupId } }; - _mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdAsync(groupId, It.IsAny())) - .ReturnsAsync(groupKeys); + _mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + groupId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((groupKeys, groupKeys.Count)); // Act var result = await _service.ListVirtualKeysAsync(groupId); @@ -54,8 +58,10 @@ public async Task ListVirtualKeysAsync_WithGroupId_ReturnsFilteredKeys() Assert.NotNull(result); Assert.Equal(2, result.Count); Assert.All(result, dto => Assert.Equal(groupId, dto.VirtualKeyGroupId)); - _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdAsync(groupId, It.IsAny()), Times.Once); - _mockVirtualKeyRepository.Verify(x => x.GetAllAsync(It.IsAny()), Times.Never); + _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + groupId, It.IsAny(), It.IsAny(), It.IsAny()), Times.AtLeastOnce); + _mockVirtualKeyRepository.Verify(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -65,8 +71,9 @@ public async Task ListVirtualKeysAsync_WithInvalidGroupId_ReturnsEmptyList() const int groupId = 999; var emptyList = new List(); - _mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdAsync(groupId, It.IsAny())) - .ReturnsAsync(emptyList); + _mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + groupId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((emptyList, 0)); // Act var result = await _service.ListVirtualKeysAsync(groupId); @@ -74,7 +81,8 @@ public async Task ListVirtualKeysAsync_WithInvalidGroupId_ReturnsEmptyList() // Assert Assert.NotNull(result); Assert.Empty(result); - _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdAsync(groupId, It.IsAny()), Times.Once); + _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + groupId, It.IsAny(), It.IsAny(), It.IsAny()), Times.AtLeastOnce); } [Fact] @@ -87,8 +95,9 @@ public async Task ListVirtualKeysAsync_LogsCorrectMessage_ForGroupFilter() new VirtualKey { Id = 1, KeyName = "Key1", VirtualKeyGroupId = groupId } }; - _mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdAsync(groupId, It.IsAny())) - .ReturnsAsync(groupKeys); + _mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + groupId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((groupKeys, groupKeys.Count)); // Act await _service.ListVirtualKeysAsync(groupId); @@ -96,7 +105,7 @@ public async Task ListVirtualKeysAsync_LogsCorrectMessage_ForGroupFilter() // Assert _mockLogger.Verify( x => x.Log( - LogLevel.Information, + LogLevel.Debug, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains($"Listing virtual keys for group {groupId}")), It.IsAny(), @@ -115,8 +124,9 @@ public async Task ListVirtualKeysAsync_HandlesNullGroupId() new VirtualKey { Id = 2, KeyName = "Key2", VirtualKeyGroupId = 2 } }; - _mockVirtualKeyRepository.Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(allKeys); + _mockVirtualKeyRepository.Setup(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((allKeys, allKeys.Count)); // Act var result = await _service.ListVirtualKeysAsync(groupId); @@ -124,8 +134,10 @@ public async Task ListVirtualKeysAsync_HandlesNullGroupId() // Assert Assert.NotNull(result); Assert.Equal(2, result.Count); - _mockVirtualKeyRepository.Verify(x => x.GetAllAsync(It.IsAny()), Times.Once); - _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockVirtualKeyRepository.Verify(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.AtLeastOnce); + _mockVirtualKeyRepository.Verify(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Maintenance.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Maintenance.cs index 54b1b4d54..e2aa14fa5 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Maintenance.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Maintenance.cs @@ -1,7 +1,5 @@ using ConduitLLM.Configuration.Entities; -using Moq; - namespace ConduitLLM.Tests.Admin.Services { public partial class AdminVirtualKeyServiceTests @@ -12,45 +10,50 @@ public partial class AdminVirtualKeyServiceTests public async Task PerformMaintenanceAsync_ProcessesExpiredKeys() { // Arrange - var keys = new List + var now = DateTime.UtcNow; + await using (var seed = await _dbContextFactory.CreateDbContextAsync()) { - // Expired key that should be disabled - new VirtualKey + seed.VirtualKeyGroups.Add(new VirtualKeyGroup { Id = 1, - KeyName = "Expired Key", - IsEnabled = true, - ExpiresAt = DateTime.UtcNow.AddDays(-1), - VirtualKeyGroupId = 1 - }, - // Valid key that shouldn't change - new VirtualKey - { - Id = 2, - KeyName = "Valid Key", - IsEnabled = true, - ExpiresAt = DateTime.UtcNow.AddDays(30), - VirtualKeyGroupId = 1 - } - }; - - _mockVirtualKeyRepository.Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(keys); - - _mockVirtualKeyRepository.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); + GroupName = "Test Group" + }); + seed.VirtualKeys.AddRange( + new VirtualKey + { + Id = 1, + KeyName = "Expired Key", + KeyHash = "hash-expired", + IsEnabled = true, + ExpiresAt = now.AddDays(-1), + VirtualKeyGroupId = 1 + }, + new VirtualKey + { + Id = 2, + KeyName = "Valid Key", + KeyHash = "hash-valid", + IsEnabled = true, + ExpiresAt = now.AddDays(30), + VirtualKeyGroupId = 1 + }); + await seed.SaveChangesAsync(); + } // Act await _service.PerformMaintenanceAsync(); - // Assert - // Verify expired key was disabled - Assert.False(keys[0].IsEnabled); - - // Only the expired key should be updated - _mockVirtualKeyRepository.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Once); + // Assert โ€” bulk update disables the expired key in the DB; the valid key is untouched. + await using var verify = await _dbContextFactory.CreateDbContextAsync(); + var expired = await verify.VirtualKeys.FindAsync(1); + var valid = await verify.VirtualKeys.FindAsync(2); + + Assert.NotNull(expired); + Assert.NotNull(valid); + Assert.False(expired!.IsEnabled); + Assert.True(valid!.IsEnabled); } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.BasicValidation.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.BasicValidation.cs index 3a5d38f3b..5aaacdacb 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.BasicValidation.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.BasicValidation.cs @@ -153,7 +153,7 @@ public async Task ValidateVirtualKeyAsync_GroupBudgetDepleted_ReturnsInvalidWith _mockVirtualKeyRepository.Setup(x => x.GetByKeyHashAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(virtualKey); - _mockGroupRepository.Setup(x => x.GetByKeyIdAsync(1)) + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) .ReturnsAsync(group); // Act diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.ModelRestrictions.cs b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.ModelRestrictions.cs index dcfb791c8..419c05f83 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.ModelRestrictions.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AdminVirtualKeyServiceTests.Validate.ModelRestrictions.cs @@ -65,7 +65,7 @@ public async Task ValidateVirtualKeyAsync_ValidKeyWithAllowedModel_ReturnsValid( LifetimeCreditsAdded = 100m, LifetimeSpent = 50m }; - _mockGroupRepository.Setup(x => x.GetByKeyIdAsync(1)) + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) .ReturnsAsync(group); // Act @@ -100,7 +100,7 @@ public async Task ValidateVirtualKeyAsync_ValidKeyWithWildcardModel_ReturnsValid .ReturnsAsync(virtualKey); var group = new VirtualKeyGroup { Id = 1, Balance = 75m }; - _mockGroupRepository.Setup(x => x.GetByKeyIdAsync(1)) + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) .ReturnsAsync(group); // Act @@ -133,7 +133,7 @@ public async Task ValidateVirtualKeyAsync_ValidKeyNoModelRestriction_ReturnsVali .ReturnsAsync(virtualKey); var group = new VirtualKeyGroup { Id = 1, Balance = 100m }; - _mockGroupRepository.Setup(x => x.GetByKeyIdAsync(1)) + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) .ReturnsAsync(group); // Act @@ -166,7 +166,7 @@ public async Task ValidateVirtualKeyAsync_ModelWithSpacesAndCase_HandlesCorrectl .ReturnsAsync(virtualKey); var group = new VirtualKeyGroup { Id = 1, Balance = 500m }; - _mockGroupRepository.Setup(x => x.GetByKeyIdAsync(1)) + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) .ReturnsAsync(group); // Act @@ -198,7 +198,7 @@ public async Task ValidateVirtualKeyAsync_ComplexWildcardPattern_HandlesCorrectl .ReturnsAsync(virtualKey); var group = new VirtualKeyGroup { Id = 1, Balance = 250m }; - _mockGroupRepository.Setup(x => x.GetByKeyIdAsync(1)) + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) .ReturnsAsync(group); // Act & Assert - Multiple model tests diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Analytics.cs b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Analytics.cs index fbdfbe616..b28808567 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Analytics.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Analytics.cs @@ -1,4 +1,4 @@ -using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.DTOs; using Moq; @@ -14,54 +14,58 @@ public partial class AnalyticsServiceTests [Fact] public async Task GetAnalyticsSummaryAsync_CalculatesMetrics() { - // Arrange - var testLogs = new List + // Arrange โ€” mock database-level aggregation methods + var summary = new RequestLogSummary { - new() { - ModelName = "gpt-4", - Cost = 0.05m, - InputTokens = 100, - OutputTokens = 50, - ResponseTimeMs = 1500, - StatusCode = 200, - Timestamp = DateTime.UtcNow, - VirtualKeyId = 1 - }, - new() { - ModelName = "gpt-3.5-turbo", - Cost = 0.02m, - InputTokens = 200, - OutputTokens = 100, - ResponseTimeMs = 800, - StatusCode = 200, - Timestamp = DateTime.UtcNow, - VirtualKeyId = 2 - }, - new() { - ModelName = "gpt-4", - Cost = 0.00m, - InputTokens = 50, - OutputTokens = 0, - ResponseTimeMs = 500, - StatusCode = 429, // Error - Timestamp = DateTime.UtcNow, - VirtualKeyId = 1 - } + TotalRequests = 3, + TotalCost = 0.07m, + TotalInputTokens = 350, + TotalOutputTokens = 150, + AverageResponseTimeMs = 933.33, + SuccessCount = 2, + ErrorCount = 1 }; - - var virtualKeys = new List + + var modelAggregations = new List + { + new() { ModelName = "gpt-4", TotalCost = 0.05m, RequestCount = 2, InputTokens = 150, OutputTokens = 50 }, + new() { ModelName = "gpt-3.5-turbo", TotalCost = 0.02m, RequestCount = 1, InputTokens = 200, OutputTokens = 100 } + }; + + var virtualKeyAggregations = new List { - new() { Id = 1, KeyName = "Production Key" }, - new() { Id = 2, KeyName = "Development Key" } + new() { VirtualKeyId = 1, TotalCost = 0.05m, RequestCount = 2, LastUsed = DateTime.UtcNow, UniqueModels = 1 }, + new() { VirtualKeyId = 2, TotalCost = 0.02m, RequestCount = 1, LastUsed = DateTime.UtcNow, UniqueModels = 1 } }; - + + var dailyStats = new List + { + new() { Date = DateTime.UtcNow.Date, RequestCount = 3, Cost = 0.07m, InputTokens = 350, OutputTokens = 150, AverageResponseTime = 933.33, ErrorCount = 1 } + }; + _mockRequestLogRepository - .Setup(x => x.GetByDateRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(testLogs); - + .Setup(x => x.GetSummaryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(summary); + + _mockRequestLogRepository + .Setup(x => x.GetAggregatedByModelAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(modelAggregations); + + _mockRequestLogRepository + .Setup(x => x.GetAggregatedByVirtualKeyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(virtualKeyAggregations); + + _mockRequestLogRepository + .Setup(x => x.GetDailyStatisticsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(dailyStats); + _mockVirtualKeyRepository - .Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(virtualKeys); + .Setup(x => x.GetKeyNamesByIdsAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new Dictionary + { + { 1, "Production Key" }, + { 2, "Development Key" } + }); // Act var result = await _service.GetAnalyticsSummaryAsync(); @@ -81,4 +85,4 @@ public async Task GetAnalyticsSummaryAsync_CalculatesMetrics() #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.CostAnalytics.cs b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.CostAnalytics.cs index 3ef69af90..1cca98c45 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.CostAnalytics.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.CostAnalytics.cs @@ -1,4 +1,4 @@ -using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.DTOs; using Moq; @@ -14,37 +14,36 @@ public partial class AnalyticsServiceTests [Fact] public async Task GetCostSummaryAsync_CalculatesTotals() { - // Arrange - var testLogs = new List + // Arrange โ€” mock database-level aggregation methods + var modelAggregations = new List { - new() { - ModelName = "gpt-4", - Cost = 0.05m, - Timestamp = DateTime.UtcNow.AddHours(-12), // Within last 24 hours - InputTokens = 100, - OutputTokens = 50 - }, - new() { - ModelName = "gpt-3.5-turbo", - Cost = 0.02m, - Timestamp = DateTime.UtcNow.AddDays(-2), - InputTokens = 200, - OutputTokens = 100 - } + new() { ModelName = "gpt-4", TotalCost = 0.05m, RequestCount = 1, InputTokens = 100, OutputTokens = 50 }, + new() { ModelName = "gpt-3.5-turbo", TotalCost = 0.02m, RequestCount = 1, InputTokens = 200, OutputTokens = 100 } }; - - var virtualKeys = new List + + var dailyCosts = new List { - new() { Id = 1, KeyName = "Test Key 1" } + new() { Date = DateTime.UtcNow.Date, TotalCost = 0.05m, RequestCount = 1 }, + new() { Date = DateTime.UtcNow.AddDays(-2).Date, TotalCost = 0.02m, RequestCount = 1 } }; - + + var last24hSummary = new RequestLogSummary { TotalRequests = 1, TotalCost = 0.05m }; + + _mockRequestLogRepository + .Setup(x => x.GetAggregatedByModelAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(modelAggregations); + _mockRequestLogRepository - .Setup(x => x.GetByDateRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(testLogs); - - _mockVirtualKeyRepository - .Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(virtualKeys); + .Setup(x => x.GetAggregatedByVirtualKeyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + _mockRequestLogRepository + .Setup(x => x.GetCostsByDateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(dailyCosts); + + _mockRequestLogRepository + .Setup(x => x.GetSummaryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(last24hSummary); // Act var result = await _service.GetCostSummaryAsync(); @@ -59,21 +58,33 @@ public async Task GetCostSummaryAsync_CalculatesTotals() [Fact] public async Task GetCostSummaryAsync_GroupsByModel() { - // Arrange - var testLogs = new List + // Arrange โ€” pre-aggregated model data (as the DB would return) + var modelAggregations = new List { - new() { ModelName = "gpt-4", Cost = 0.05m, Timestamp = DateTime.UtcNow }, - new() { ModelName = "gpt-4", Cost = 0.03m, Timestamp = DateTime.UtcNow }, - new() { ModelName = "claude-3", Cost = 0.02m, Timestamp = DateTime.UtcNow } + new() { ModelName = "gpt-4", TotalCost = 0.08m, RequestCount = 2, InputTokens = 300, OutputTokens = 100 }, + new() { ModelName = "claude-3", TotalCost = 0.02m, RequestCount = 1, InputTokens = 100, OutputTokens = 50 } }; - + + var dailyCosts = new List + { + new() { Date = DateTime.UtcNow.Date, TotalCost = 0.10m, RequestCount = 3 } + }; + + _mockRequestLogRepository + .Setup(x => x.GetAggregatedByModelAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(modelAggregations); + + _mockRequestLogRepository + .Setup(x => x.GetAggregatedByVirtualKeyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + _mockRequestLogRepository + .Setup(x => x.GetCostsByDateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(dailyCosts); + _mockRequestLogRepository - .Setup(x => x.GetByDateRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(testLogs); - - _mockVirtualKeyRepository - .Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(new List()); + .Setup(x => x.GetSummaryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new RequestLogSummary { TotalRequests = 3, TotalCost = 0.10m }); // Act var result = await _service.GetCostSummaryAsync(); @@ -87,4 +98,4 @@ public async Task GetCostSummaryAsync_GroupsByModel() #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Export.cs b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Export.cs index f41a4a7e7..0295e3a69 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Export.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Export.cs @@ -32,7 +32,8 @@ public async Task ExportAnalyticsAsync_CSV_GeneratesCorrectFormat() }; _mockRequestLogRepository - .Setup(x => x.GetByDateRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetByDateRangeFilteredAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(testLogs); // Act @@ -61,7 +62,8 @@ public async Task ExportAnalyticsAsync_JSON_GeneratesCorrectFormat() }; _mockRequestLogRepository - .Setup(x => x.GetByDateRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetByDateRangeFilteredAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(testLogs); // Act diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Models.cs b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Models.cs index 2503f6421..c129fd1bf 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Models.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.Models.cs @@ -1,5 +1,3 @@ -using ConduitLLM.Configuration.Entities; - using Moq; namespace ConduitLLM.Tests.Admin.Services @@ -14,18 +12,12 @@ public partial class AnalyticsServiceTests [Fact] public async Task GetDistinctModelsAsync_ReturnsUniqueModels() { - // Arrange - var testLogs = new List - { - new() { ModelName = "gpt-4" }, - new() { ModelName = "gpt-3.5-turbo" }, - new() { ModelName = "gpt-4" }, // Duplicate - new() { ModelName = "claude-3" } - }; - + // Arrange - Repository now returns pre-filtered distinct models + var distinctModels = new List { "claude-3", "gpt-3.5-turbo", "gpt-4" }; + _mockRequestLogRepository - .Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(testLogs); + .Setup(x => x.GetDistinctModelsAsync(It.IsAny())) + .ReturnsAsync(distinctModels); // Act var result = await _service.GetDistinctModelsAsync(); @@ -41,22 +33,19 @@ public async Task GetDistinctModelsAsync_ReturnsUniqueModels() [Fact] public async Task GetDistinctModelsAsync_UsesCaching() { - // Arrange - var testLogs = new List - { - new() { ModelName = "gpt-4" } - }; - + // Arrange - Repository now returns pre-filtered distinct models + var distinctModels = new List { "gpt-4" }; + _mockRequestLogRepository - .Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(testLogs); + .Setup(x => x.GetDistinctModelsAsync(It.IsAny())) + .ReturnsAsync(distinctModels); // Act - Call twice var result1 = await _service.GetDistinctModelsAsync(); var result2 = await _service.GetDistinctModelsAsync(); // Assert - Repository should only be called once due to caching - _mockRequestLogRepository.Verify(x => x.GetAllAsync(It.IsAny()), Times.Once); + _mockRequestLogRepository.Verify(x => x.GetDistinctModelsAsync(It.IsAny()), Times.Once); Assert.Equal(result1, result2); } diff --git a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.VirtualKeyUsage.cs b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.VirtualKeyUsage.cs index 8911ef71d..97f5fd8c4 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.VirtualKeyUsage.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/AnalyticsServiceTests.VirtualKeyUsage.cs @@ -1,4 +1,4 @@ -using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.DTOs; using Moq; @@ -14,41 +14,30 @@ public partial class AnalyticsServiceTests [Fact] public async Task GetVirtualKeyUsageAsync_FiltersById() { - // Arrange - var testLogs = new List + // Arrange โ€” mock database-level aggregation for virtual key 1 + var summary = new RequestLogSummary { - new() { - VirtualKeyId = 1, - ModelName = "gpt-4", - Cost = 0.05m, - InputTokens = 100, - OutputTokens = 50, - ResponseTimeMs = 1500, - Timestamp = DateTime.UtcNow - }, - new() { - VirtualKeyId = 2, // Different key - ModelName = "gpt-3.5-turbo", - Cost = 0.02m, - InputTokens = 200, - OutputTokens = 100, - ResponseTimeMs = 800, - Timestamp = DateTime.UtcNow - }, - new() { - VirtualKeyId = 1, - ModelName = "gpt-4", - Cost = 0.03m, - InputTokens = 150, - OutputTokens = 75, - ResponseTimeMs = 1200, - Timestamp = DateTime.UtcNow - } + TotalRequests = 2, + TotalCost = 0.08m, + TotalInputTokens = 250, + TotalOutputTokens = 125, + AverageResponseTimeMs = 1350, + SuccessCount = 2, + ErrorCount = 0 }; - + + var modelAggregations = new List + { + new() { ModelName = "gpt-4", TotalCost = 0.08m, RequestCount = 2, InputTokens = 250, OutputTokens = 125 } + }; + + _mockRequestLogRepository + .Setup(x => x.GetSummaryForVirtualKeyAsync(1, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(summary); + _mockRequestLogRepository - .Setup(x => x.GetByDateRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(testLogs); + .Setup(x => x.GetAggregatedByModelForVirtualKeyAsync(1, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(modelAggregations); // Act var result = await _service.GetVirtualKeyUsageAsync(1); @@ -59,11 +48,11 @@ public async Task GetVirtualKeyUsageAsync_FiltersById() Assert.Equal(0.08m, result.TotalCost); Assert.Equal(250, result.TotalInputTokens); Assert.Equal(125, result.TotalOutputTokens); - Assert.Equal(1350, result.AverageResponseTimeMs); // (1500 + 1200) / 2 + Assert.Equal(1350, result.AverageResponseTimeMs); Assert.Single(result.ModelUsage); Assert.Equal("gpt-4", result.ModelUsage.Keys.First()); } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/ModelProviderTypeAssociationCostTests.cs b/Tests/ConduitLLM.Tests/Admin/Services/ModelProviderTypeAssociationCostTests.cs index 2efcbe1e0..e8d45d3d3 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/ModelProviderTypeAssociationCostTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/ModelProviderTypeAssociationCostTests.cs @@ -7,7 +7,6 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Moq; @@ -22,7 +21,6 @@ public class ModelProviderTypeAssociationCostTests : IDisposable private readonly Mock _mockModelCostRepository; private readonly Mock _mockModelProviderMappingRepository; private readonly Mock> _mockLogger; - private readonly IMemoryCache _cache; private readonly ModelCostService _service; private readonly DbContextOptions _dbOptions; @@ -31,12 +29,10 @@ public ModelProviderTypeAssociationCostTests() _mockModelCostRepository = new Mock(); _mockModelProviderMappingRepository = new Mock(); _mockLogger = new Mock>(); - _cache = new MemoryCache(new MemoryCacheOptions()); - + _service = new ModelCostService( _mockModelCostRepository.Object, _mockModelProviderMappingRepository.Object, - _cache, _mockLogger.Object ); @@ -72,8 +68,10 @@ public async Task GetCostForModelAsync_WithDirectAssociation_ShouldReturnCorrect } }; - _mockModelCostRepository.Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(new List { expectedCost }); + var costs = new List { expectedCost }; + _mockModelCostRepository.Setup(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((costs, costs.Count)); // Act var result = await _service.GetCostForModelAsync(modelIdentifier); @@ -123,8 +121,9 @@ public async Task GetCostForModelAsync_WithMultipleCosts_ShouldReturnHighestPrio } }; - _mockModelCostRepository.Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(costs); + _mockModelCostRepository.Setup(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((costs, costs.Count)); // Act var result = await _service.GetCostForModelAsync(modelIdentifier); @@ -158,8 +157,10 @@ public async Task GetCostForModelAsync_WithDisabledAssociation_ShouldNotReturnCo } }; - _mockModelCostRepository.Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(new List { cost }); + var costs = new List { cost }; + _mockModelCostRepository.Setup(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((costs, costs.Count)); // Act var result = await _service.GetCostForModelAsync(modelIdentifier); @@ -192,8 +193,10 @@ public async Task GetCostForModelAsync_WithExpiredCost_ShouldNotReturnCost() } }; - _mockModelCostRepository.Setup(x => x.GetAllAsync(It.IsAny())) - .ReturnsAsync(new List { cost }); + var costs = new List { cost }; + _mockModelCostRepository.Setup(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((costs, costs.Count)); // Act var result = await _service.GetCostForModelAsync(modelIdentifier); @@ -290,7 +293,6 @@ public async Task MultiplAssociations_CanShareSameCost() public void Dispose() { - _cache?.Dispose(); } } } \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Admin/Services/RefundServiceTests.cs b/Tests/ConduitLLM.Tests/Admin/Services/RefundServiceTests.cs new file mode 100644 index 000000000..cb7004617 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Admin/Services/RefundServiceTests.cs @@ -0,0 +1,156 @@ +using ConduitLLM.Admin.Services; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ConduitLLM.Tests.Admin.Services; + +public class RefundServiceTests +{ + private readonly Mock _mockCostCalculationService; + private readonly Mock _mockGroupRepository; + private readonly Mock _mockContext; + private readonly Mock> _mockLogger; + private readonly RefundService _service; + + public RefundServiceTests() + { + _mockCostCalculationService = new Mock(); + _mockGroupRepository = new Mock(); + _mockContext = new Mock(); + _mockLogger = new Mock>(); + + _service = new RefundService( + _mockCostCalculationService.Object, + _mockGroupRepository.Object, + _mockContext.Object, + _mockLogger.Object); + } + + [Fact] + public async Task ProcessRefundAsync_WithValidData_ShouldUpdateBalanceAndReturnResult() + { + // Arrange + var groupId = 1; + var modelId = "gpt-4"; + var originalUsage = new Usage { PromptTokens = 1000, CompletionTokens = 500, TotalTokens = 1500 }; + var refundUsage = new Usage { PromptTokens = 1000, CompletionTokens = 500, TotalTokens = 1500 }; + var group = new VirtualKeyGroup { Id = groupId, Balance = 50.00m, UpdatedAt = DateTime.UtcNow }; + + var refundResult = new RefundResult + { + ModelId = modelId, + RefundAmount = 0.15m, + RefundReason = "Incorrect response", + ValidationMessages = new List() + }; + + _mockGroupRepository.Setup(x => x.GetByIdAsync(groupId, It.IsAny())) + .ReturnsAsync(group); + _mockCostCalculationService.Setup(x => x.CalculateRefundAsync( + modelId, originalUsage, refundUsage, "Incorrect response", null, It.IsAny())) + .ReturnsAsync(refundResult); + _mockContext.Setup(x => x.VirtualKeyGroups).Returns(Mock.Of>()); + _mockContext.Setup(x => x.VirtualKeyGroupTransactions).Returns(Mock.Of>()); + _mockContext.Setup(x => x.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); + + // Act + var result = await _service.ProcessRefundAsync( + groupId, modelId, originalUsage, refundUsage, + "Incorrect response", null, "admin", null); + + // Assert + result.Should().NotBeNull(); + result.RefundAmount.Should().Be(0.15m); + group.Balance.Should().Be(50.15m); + } + + [Fact] + public async Task ProcessRefundAsync_WithNonExistentGroup_ShouldThrowInvalidOperationException() + { + // Arrange + _mockGroupRepository.Setup(x => x.GetByIdAsync(999, It.IsAny())) + .ReturnsAsync((VirtualKeyGroup?)null); + + // Act + var act = () => _service.ProcessRefundAsync( + 999, "gpt-4", + new Usage { PromptTokens = 100, TotalTokens = 100 }, + new Usage { PromptTokens = 100, TotalTokens = 100 }, + "reason", null, "admin", null); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*999*not found*"); + } + + [Fact] + public async Task ProcessRefundAsync_WithValidationErrors_ShouldThrowArgumentException() + { + // Arrange + var group = new VirtualKeyGroup { Id = 1, Balance = 50.00m }; + var refundResult = new RefundResult + { + RefundAmount = 0, + ValidationMessages = new List { "Model not found in cost configuration" } + }; + + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(group); + _mockCostCalculationService.Setup(x => x.CalculateRefundAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(refundResult); + + // Act + var act = () => _service.ProcessRefundAsync( + 1, "unknown-model", + new Usage { PromptTokens = 100, TotalTokens = 100 }, + new Usage { PromptTokens = 100, TotalTokens = 100 }, + "reason", null, "admin", null); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*validation failed*"); + } + + [Fact] + public async Task ProcessRefundAsync_WithValidationWarningsButNonZeroRefund_ShouldSucceed() + { + // Arrange + var group = new VirtualKeyGroup { Id = 1, Balance = 10.00m, UpdatedAt = DateTime.UtcNow }; + var refundResult = new RefundResult + { + ModelId = "gpt-4", + RefundAmount = 0.05m, + RefundReason = "partial", + ValidationMessages = new List { "Partial refund: output tokens capped" } + }; + + _mockGroupRepository.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(group); + _mockCostCalculationService.Setup(x => x.CalculateRefundAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(refundResult); + _mockContext.Setup(x => x.VirtualKeyGroups).Returns(Mock.Of>()); + _mockContext.Setup(x => x.VirtualKeyGroupTransactions).Returns(Mock.Of>()); + _mockContext.Setup(x => x.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); + + // Act + var result = await _service.ProcessRefundAsync( + 1, "gpt-4", + new Usage { PromptTokens = 1000, TotalTokens = 1000 }, + new Usage { PromptTokens = 500, TotalTokens = 500 }, + "partial", null, "admin", null); + + // Assert + result.Should().NotBeNull(); + result.RefundAmount.Should().Be(0.05m); + group.Balance.Should().Be(10.05m); + } +} diff --git a/Tests/ConduitLLM.Tests/Admin/Services/SecurityServiceTests.cs b/Tests/ConduitLLM.Tests/Admin/Services/SecurityServiceTests.cs index e81ae0495..530ed266f 100644 --- a/Tests/ConduitLLM.Tests/Admin/Services/SecurityServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Admin/Services/SecurityServiceTests.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; -using ConduitLLM.Admin.Options; +using ConduitLLM.Security.Options; using ConduitLLM.Admin.Services; namespace ConduitLLM.Tests.Admin.Services @@ -18,7 +18,7 @@ public class SecurityServiceTests private readonly Mock _memoryCacheMock; private readonly Mock _distributedCacheMock; private readonly Mock _serviceScopeFactoryMock; - private readonly IOptions _securityOptions; + private readonly IOptions _securityOptions; private readonly SecurityService _securityService; public SecurityServiceTests() @@ -29,17 +29,18 @@ public SecurityServiceTests() _distributedCacheMock = new Mock(); _serviceScopeFactoryMock = new Mock(); - var securityOptions = new SecurityOptions + var securityOptions = new AdminSecurityOptions { ApiAuth = new ApiAuthOptions { ApiKeyHeader = "X-API-Key", AlternativeHeaders = new List { "X-Master-Key" } - }, - RateLimiting = new RateLimitingOptions { Enabled = false }, - IpFiltering = new IpFilteringOptions { Enabled = false }, - FailedAuth = new FailedAuthOptions { Enabled = false } + } }; + // Disable security features for testing + securityOptions.RateLimiting.Enabled = false; + securityOptions.IpFiltering.Enabled = false; + securityOptions.FailedAuth.Enabled = false; _securityOptions = Microsoft.Extensions.Options.Options.Create(securityOptions); _securityService = new SecurityService( diff --git a/Tests/ConduitLLM.Tests/Admin/TestHelpers/LoggerMockExtensions.cs b/Tests/ConduitLLM.Tests/Admin/TestHelpers/LoggerMockExtensions.cs deleted file mode 100644 index ff41647af..000000000 --- a/Tests/ConduitLLM.Tests/Admin/TestHelpers/LoggerMockExtensions.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.Extensions.Logging; - -using Moq; - -namespace ConduitLLM.Tests.Admin.TestHelpers -{ - /// - /// Extension methods for mocking ILogger in Admin tests. - /// - public static class LoggerMockExtensions - { - /// - /// Verifies that a log message was written at the specified level containing the expected text. - /// - public static void VerifyLog(this Mock> mock, LogLevel level, string containsMessage, - Times? times = null) - { - times ??= Times.Once(); - - mock.Verify(x => x.Log( - level, - It.IsAny(), - It.Is((o, t) => o.ToString().Contains(containsMessage)), - It.IsAny(), - It.IsAny>()), - times.Value); - } - - /// - /// Verifies that a log message was written with a specific exception. - /// - public static void VerifyLogWithException(this Mock> mock, LogLevel level, - Exception exception, string containsMessage = null) - { - mock.Verify(x => x.Log( - level, - It.IsAny(), - It.Is((o, t) => containsMessage == null || o.ToString().Contains(containsMessage)), - exception, - It.IsAny>()), - Times.Once()); - } - - /// - /// Verifies that a log message was written with any exception. - /// - public static void VerifyLogWithAnyException(this Mock> mock, LogLevel level, - string containsMessage = null) - { - mock.Verify(x => x.Log( - level, - It.IsAny(), - It.Is((o, t) => containsMessage == null || o.ToString().Contains(containsMessage)), - It.IsAny(), - It.IsAny>()), - Times.Once()); - } - - /// - /// Verifies that no logs were written at the specified level. - /// - public static void VerifyNoLog(this Mock> mock, LogLevel level) - { - mock.Verify(x => x.Log( - level, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()), - Times.Never()); - } - } -} \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/ConduitLLM.Tests.csproj b/Tests/ConduitLLM.Tests/ConduitLLM.Tests.csproj index ad5eacb42..69934ec79 100644 --- a/Tests/ConduitLLM.Tests/ConduitLLM.Tests.csproj +++ b/Tests/ConduitLLM.Tests/ConduitLLM.Tests.csproj @@ -9,15 +9,18 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + + + + + @@ -25,8 +28,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + diff --git a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Create.cs b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Create.cs index c4ff42c65..a316cdeb0 100644 --- a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Create.cs +++ b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Create.cs @@ -30,10 +30,11 @@ public async Task CreateAsync_WhenFirstEnabledKey_ShouldAutomaticallySetAsPrimar }; // Act - var result = await _repository.CreateAsync(keyCredential); + var resultId = await _repository.CreateAsync(keyCredential); // Assert - Assert.True(result.IsPrimary, "First enabled key should automatically be set as primary"); + Assert.True(keyCredential.IsPrimary, "First enabled key should automatically be set as primary"); + Assert.True(resultId > 0, "Should return the created ID"); } [Fact] @@ -74,10 +75,11 @@ public async Task CreateAsync_WhenNotFirstEnabledKey_ShouldNotAutomaticallySetAs }; // Act - var result = await _repository.CreateAsync(secondKeyCredential); + var resultId = await _repository.CreateAsync(secondKeyCredential); // Assert - Assert.False(result.IsPrimary, "Second enabled key should not automatically be set as primary"); + Assert.False(secondKeyCredential.IsPrimary, "Second enabled key should not automatically be set as primary"); + Assert.True(resultId > 0, "Should return the created ID"); } [Fact] @@ -105,10 +107,11 @@ public async Task CreateAsync_WhenDisabled_ShouldNotAutomaticallySetAsPrimary() }; // Act - var result = await _repository.CreateAsync(keyCredential); + var resultId = await _repository.CreateAsync(keyCredential); // Assert - Assert.False(result.IsPrimary, "Disabled key should not automatically be set as primary"); + Assert.False(keyCredential.IsPrimary, "Disabled key should not automatically be set as primary"); + Assert.True(resultId > 0, "Should return the created ID"); } [Fact] @@ -136,10 +139,11 @@ public async Task CreateAsync_WhenExplicitlySetAsPrimary_ShouldStayPrimary() }; // Act - var result = await _repository.CreateAsync(keyCredential); + var resultId = await _repository.CreateAsync(keyCredential); // Assert - Assert.True(result.IsPrimary, "Explicitly set primary should remain primary"); + Assert.True(keyCredential.IsPrimary, "Explicitly set primary should remain primary"); + Assert.True(resultId > 0, "Should return the created ID"); } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.SetPrimary.cs b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.SetPrimary.cs index 130136131..684edbd27 100644 --- a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.SetPrimary.cs +++ b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.SetPrimary.cs @@ -52,11 +52,12 @@ public async Task SetPrimaryKeyAsync_WithExistingPrimary_ShouldUpdateCorrectly() // Assert Assert.True(result); - - var keys = await _context.ProviderKeyCredentials + + using var verifyContext = CreateVerificationContext(); + var keys = await verifyContext.ProviderKeyCredentials .Where(k => k.ProviderId == 1) .ToListAsync(); - + Assert.Equal(2, keys.Count); Assert.False(keys.First(k => k.Id == 1).IsPrimary); Assert.True(keys.First(k => k.Id == 2).IsPrimary); @@ -95,9 +96,10 @@ public async Task SetPrimaryKeyAsync_WithNoPrimary_ShouldSetPrimary() // Assert Assert.True(result); - - var updatedKey = await _context.ProviderKeyCredentials.FindAsync(1); - Assert.True(updatedKey.IsPrimary); + + using var verifyContext = CreateVerificationContext(); + var updatedKey = await verifyContext.ProviderKeyCredentials.FindAsync(1); + Assert.True(updatedKey!.IsPrimary); } [Fact] @@ -212,7 +214,7 @@ public async Task SetPrimaryKeyAsync_WithMultiplePrimaryKeys_ShouldFixDataCorrup }; _context.ProviderKeyCredentials.AddRange(key1, key2, key3); - + // Save without constraint validation (simulating corruption) _context.ChangeTracker.AutoDetectChangesEnabled = false; await _context.SaveChangesAsync(); @@ -223,11 +225,12 @@ public async Task SetPrimaryKeyAsync_WithMultiplePrimaryKeys_ShouldFixDataCorrup // Assert Assert.True(result); - - var keys = await _context.ProviderKeyCredentials + + using var verifyContext = CreateVerificationContext(); + var keys = await verifyContext.ProviderKeyCredentials .Where(k => k.ProviderId == 1) .ToListAsync(); - + Assert.Equal(3, keys.Count); Assert.False(keys.First(k => k.Id == 1).IsPrimary); Assert.False(keys.First(k => k.Id == 2).IsPrimary); @@ -323,10 +326,11 @@ public async Task SetPrimaryKeyAsync_ShouldUpdateTimestamps() // Assert Assert.True(result); - - var updatedKey = await _context.ProviderKeyCredentials.FindAsync(1); - Assert.True(updatedKey.UpdatedAt > originalTime); + + using var verifyContext = CreateVerificationContext(); + var updatedKey = await verifyContext.ProviderKeyCredentials.FindAsync(1); + Assert.True(updatedKey!.UpdatedAt > originalTime); Assert.Equal(originalTime, updatedKey.CreatedAt); // CreatedAt should not change } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Update.cs b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Update.cs index f1452f616..1e1f4d10e 100644 --- a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Update.cs +++ b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.Update.cs @@ -50,8 +50,9 @@ public async Task UpdateAsync_WhenEnablingOnlyKey_ShouldAutomaticallySetAsPrimar // Assert Assert.True(result); - var updatedKey = await _context.ProviderKeyCredentials.FindAsync(1); - Assert.True(updatedKey.IsPrimary, "Enabling the only key should automatically set it as primary"); + using var verifyContext = CreateVerificationContext(); + var updatedKey = await verifyContext.ProviderKeyCredentials.FindAsync(1); + Assert.True(updatedKey!.IsPrimary, "Enabling the only key should automatically set it as primary"); } [Fact] @@ -113,12 +114,13 @@ public async Task UpdateAsync_WhenEnablingWithOtherEnabledKeys_ShouldNotAutomati // Assert Assert.True(result); - var updatedKey = await _context.ProviderKeyCredentials.FindAsync(2); - Assert.False(updatedKey.IsPrimary, "Enabling a key when other enabled keys exist should not automatically set it as primary"); - + using var verifyContext = CreateVerificationContext(); + var updatedKey = await verifyContext.ProviderKeyCredentials.FindAsync(2); + Assert.False(updatedKey!.IsPrimary, "Enabling a key when other enabled keys exist should not automatically set it as primary"); + // Verify first key is still primary - var firstKeyAfterUpdate = await _context.ProviderKeyCredentials.FindAsync(1); - Assert.True(firstKeyAfterUpdate.IsPrimary, "First key should remain primary"); + var firstKeyAfterUpdate = await verifyContext.ProviderKeyCredentials.FindAsync(1); + Assert.True(firstKeyAfterUpdate!.IsPrimary, "First key should remain primary"); } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.cs b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.cs index 44d47e0bd..655a19b6f 100644 --- a/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.cs +++ b/Tests/ConduitLLM.Tests/Configuration/Repositories/ProviderKeyCredentialRepositoryTests.cs @@ -11,20 +11,44 @@ namespace ConduitLLM.Tests.Configuration.Repositories public partial class ProviderKeyCredentialRepositoryTests : IDisposable { private readonly ConduitDbContext _context; + private readonly DbContextOptions _options; + private readonly Mock> _mockContextFactory; private readonly ProviderKeyCredentialRepository _repository; private readonly Mock> _mockLogger; public ProviderKeyCredentialRepositoryTests() { - var options = new DbContextOptionsBuilder() + _options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .ConfigureWarnings(warnings => warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning)) .Options; - _context = new ConduitDbContext(options); + _context = new ConduitDbContext(_options); _context.IsTestEnvironment = true; + + _mockContextFactory = new Mock>(); + // The factory must return a new context each time but sharing the same in-memory database + _mockContextFactory.Setup(x => x.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => + { + var ctx = new ConduitDbContext(_options); + ctx.IsTestEnvironment = true; + return ctx; + }); + _mockLogger = new Mock>(); - _repository = new ProviderKeyCredentialRepository(_context, _mockLogger.Object); + _repository = new ProviderKeyCredentialRepository(_mockContextFactory.Object, _mockLogger.Object); + } + + /// + /// Creates a fresh context to verify database state after repository operations. + /// This is needed because the repository uses its own contexts through the factory. + /// + protected ConduitDbContext CreateVerificationContext() + { + var ctx = new ConduitDbContext(_options); + ctx.IsTestEnvironment = true; + return ctx; } public void Dispose() @@ -32,4 +56,4 @@ public void Dispose() _context.Dispose(); } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Configuration/Repositories/VirtualKeyGroupRepositoryIncludeTests.cs b/Tests/ConduitLLM.Tests/Configuration/Repositories/VirtualKeyGroupRepositoryIncludeTests.cs index ce185f3d0..8a0ae5944 100644 --- a/Tests/ConduitLLM.Tests/Configuration/Repositories/VirtualKeyGroupRepositoryIncludeTests.cs +++ b/Tests/ConduitLLM.Tests/Configuration/Repositories/VirtualKeyGroupRepositoryIncludeTests.cs @@ -15,20 +15,28 @@ namespace ConduitLLM.Tests.Configuration.Repositories /// public class VirtualKeyGroupRepositoryIncludeTests : IDisposable { - private readonly ConduitDbContext _context; + private readonly DbContextOptions _options; private readonly VirtualKeyGroupRepository _repository; private readonly Mock> _loggerMock; + private readonly Mock> _dbContextFactoryMock; public VirtualKeyGroupRepositoryIncludeTests() { // Use in-memory database for testing - var options = new DbContextOptionsBuilder() + _options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; - _context = new ConduitDbContext(options); + _dbContextFactoryMock = new Mock>(); + _dbContextFactoryMock + .Setup(f => f.CreateDbContext()) + .Returns(() => new ConduitDbContext(_options)); + _dbContextFactoryMock + .Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new ConduitDbContext(_options)); + _loggerMock = new Mock>(); - _repository = new VirtualKeyGroupRepository(_context, _loggerMock.Object); + _repository = new VirtualKeyGroupRepository(_dbContextFactoryMock.Object, _loggerMock.Object); // Seed test data SeedTestData(); @@ -36,6 +44,8 @@ public VirtualKeyGroupRepositoryIncludeTests() private void SeedTestData() { + using var context = new ConduitDbContext(_options); + // Create test groups var group1 = new VirtualKeyGroup { @@ -59,8 +69,8 @@ private void SeedTestData() UpdatedAt = DateTime.UtcNow }; - _context.VirtualKeyGroups.Add(group1); - _context.VirtualKeyGroups.Add(group2); + context.VirtualKeyGroups.Add(group1); + context.VirtualKeyGroups.Add(group2); // Create test virtual keys var key1 = new VirtualKey @@ -96,15 +106,17 @@ private void SeedTestData() UpdatedAt = DateTime.UtcNow }; - _context.VirtualKeys.AddRange(key1, key2, key3); - _context.SaveChanges(); + context.VirtualKeys.AddRange(key1, key2, key3); + context.SaveChanges(); } [Fact] public async Task GetAllAsync_Should_Include_VirtualKeys() { // Act +#pragma warning disable CS0618 // Type or member is obsolete var groups = await _repository.GetAllAsync(); +#pragma warning restore CS0618 // Type or member is obsolete // Assert Assert.NotNull(groups); @@ -139,7 +151,7 @@ public async Task GetByIdWithKeysAsync_Should_Include_VirtualKeys() } [Fact] - public async Task GetByIdAsync_Without_Include_Should_Not_Load_VirtualKeys() + public async Task GetByIdAsync_Should_Include_VirtualKeys_By_Default() { // Act var group = await _repository.GetByIdAsync(1); @@ -147,18 +159,21 @@ public async Task GetByIdAsync_Without_Include_Should_Not_Load_VirtualKeys() // Assert Assert.NotNull(group); Assert.Equal("Test Group 1", group.GroupName); - // In EF Core with in-memory database, navigation properties might still be loaded - // The important part is that the Include statement works when we need it + // With the new RepositoryBase pattern, ApplyDefaultIncludes includes VirtualKeys + Assert.NotNull(group.VirtualKeys); + Assert.Equal(2, group.VirtualKeys.Count); } [Fact] - public async Task Repository_Should_Work_With_Concrete_DbContext() + public async Task Repository_Should_Work_With_DbContextFactory() { - // This test verifies that the repository works correctly with ConfigurationDbContext - // instead of IConfigurationDbContext interface + // This test verifies that the repository works correctly with IDbContextFactory + // using the new RepositoryBase pattern // Act & Assert - various operations should work +#pragma warning disable CS0618 // Type or member is obsolete var allGroups = await _repository.GetAllAsync(); +#pragma warning restore CS0618 // Type or member is obsolete Assert.NotEmpty(allGroups); var specificGroup = await _repository.GetByIdAsync(1); @@ -169,9 +184,53 @@ public async Task Repository_Should_Work_With_Concrete_DbContext() Assert.NotEmpty(groupWithKeys.VirtualKeys); } + [Fact] + public async Task GetPaginatedAsync_Should_Return_Correct_Page() + { + // Act + var (items, totalCount) = await _repository.GetPaginatedAsync(1, 10); + + // Assert + Assert.Equal(2, totalCount); + Assert.Equal(2, items.Count); + // Should be ordered by GroupName + Assert.Equal("Test Group 1", items[0].GroupName); + Assert.Equal("Test Group 2", items[1].GroupName); + } + + [Fact] + public async Task ExistsAsync_Should_Return_True_For_Existing_Group() + { + // Act + var exists = await _repository.ExistsAsync(1); + + // Assert + Assert.True(exists); + } + + [Fact] + public async Task ExistsAsync_Should_Return_False_For_NonExisting_Group() + { + // Act + var exists = await _repository.ExistsAsync(999); + + // Assert + Assert.False(exists); + } + + [Fact] + public async Task CountAsync_Should_Return_Correct_Count() + { + // Act + var count = await _repository.CountAsync(); + + // Assert + Assert.Equal(2, count); + } + public void Dispose() { - _context?.Dispose(); + // Clean up any resources if needed } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Configuration/Repositories/VirtualKeyRepositoryTests.GetTopEnabled.cs b/Tests/ConduitLLM.Tests/Configuration/Repositories/VirtualKeyRepositoryTests.GetTopEnabled.cs new file mode 100644 index 000000000..16f962764 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Configuration/Repositories/VirtualKeyRepositoryTests.GetTopEnabled.cs @@ -0,0 +1,252 @@ +using ConduitLLM.Configuration; +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Repositories; + +using FluentAssertions; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Moq; + +using Xunit.Abstractions; + +namespace ConduitLLM.Tests.Configuration.Repositories +{ + /// + /// Unit tests for the VirtualKeyRepository.GetTopEnabledAsync method. + /// + [Trait("Category", "Unit")] + [Trait("Component", "Repository")] + public class VirtualKeyRepositoryGetTopEnabledTests : IDisposable + { + private readonly ConduitDbContext _context; + private readonly DbContextOptions _options; + private readonly Mock> _mockContextFactory; + private readonly Mock> _mockLogger; + private readonly VirtualKeyRepository _repository; + private readonly ITestOutputHelper _output; + + public VirtualKeyRepositoryGetTopEnabledTests(ITestOutputHelper output) + { + _output = output; + + // Setup in-memory database for testing + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new ConduitDbContext(_options); + _mockContextFactory = new Mock>(); + // The factory must return a new context each time to simulate production behavior + _mockContextFactory.Setup(x => x.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new ConduitDbContext(_options)); + + _mockLogger = new Mock>(); + + _repository = new VirtualKeyRepository(_mockContextFactory.Object, _mockLogger.Object); + } + + #region GetTopEnabledAsync Tests + + [Fact] + public async Task GetTopEnabledAsync_WithMultipleEnabledKeys_ReturnsRequestedCount() + { + // Arrange + var keyGroup = await CreateTestKeyGroup("Test Group"); + + // Create 5 enabled keys + for (int i = 1; i <= 5; i++) + { + await CreateTestKey($"Key {i}", $"hash{i}", keyGroup.Id, isEnabled: true); + } + + // Act + var result = await _repository.GetTopEnabledAsync(3); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().OnlyContain(k => k.IsEnabled); + } + + [Fact] + public async Task GetTopEnabledAsync_WithFewerEnabledKeysThanRequested_ReturnsAllEnabled() + { + // Arrange + var keyGroup = await CreateTestKeyGroup("Test Group"); + + // Create only 2 enabled keys + await CreateTestKey("Key 1", "hash1", keyGroup.Id, isEnabled: true); + await CreateTestKey("Key 2", "hash2", keyGroup.Id, isEnabled: true); + + // Act + var result = await _repository.GetTopEnabledAsync(10); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.Should().OnlyContain(k => k.IsEnabled); + } + + [Fact] + public async Task GetTopEnabledAsync_WithNoEnabledKeys_ReturnsEmptyList() + { + // Arrange + var keyGroup = await CreateTestKeyGroup("Test Group"); + + // Create only disabled keys + await CreateTestKey("Key 1", "hash1", keyGroup.Id, isEnabled: false); + await CreateTestKey("Key 2", "hash2", keyGroup.Id, isEnabled: false); + + // Act + var result = await _repository.GetTopEnabledAsync(5); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetTopEnabledAsync_WithMixedEnabledDisabled_ReturnsOnlyEnabled() + { + // Arrange + var keyGroup = await CreateTestKeyGroup("Test Group"); + + // Create mix of enabled and disabled keys + await CreateTestKey("Enabled Key 1", "hash1", keyGroup.Id, isEnabled: true); + await CreateTestKey("Disabled Key 1", "hash2", keyGroup.Id, isEnabled: false); + await CreateTestKey("Enabled Key 2", "hash3", keyGroup.Id, isEnabled: true); + await CreateTestKey("Disabled Key 2", "hash4", keyGroup.Id, isEnabled: false); + await CreateTestKey("Enabled Key 3", "hash5", keyGroup.Id, isEnabled: true); + + // Act + var result = await _repository.GetTopEnabledAsync(10); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().OnlyContain(k => k.IsEnabled); + result.Select(k => k.KeyName).Should().NotContain(name => name.Contains("Disabled")); + } + + [Fact] + public async Task GetTopEnabledAsync_ReturnsKeysOrderedByKeyName() + { + // Arrange + var keyGroup = await CreateTestKeyGroup("Test Group"); + + // Create keys in non-alphabetical order + await CreateTestKey("Zebra Key", "hashZ", keyGroup.Id, isEnabled: true); + await CreateTestKey("Alpha Key", "hashA", keyGroup.Id, isEnabled: true); + await CreateTestKey("Mike Key", "hashM", keyGroup.Id, isEnabled: true); + await CreateTestKey("Beta Key", "hashB", keyGroup.Id, isEnabled: true); + + // Act + var result = await _repository.GetTopEnabledAsync(10); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(4); + result.Should().BeInAscendingOrder(k => k.KeyName); + result[0].KeyName.Should().Be("Alpha Key"); + result[1].KeyName.Should().Be("Beta Key"); + result[2].KeyName.Should().Be("Mike Key"); + result[3].KeyName.Should().Be("Zebra Key"); + } + + [Fact] + public async Task GetTopEnabledAsync_WithZeroCount_ReturnsEmptyList() + { + // Arrange + var keyGroup = await CreateTestKeyGroup("Test Group"); + await CreateTestKey("Key 1", "hash1", keyGroup.Id, isEnabled: true); + + // Act + var result = await _repository.GetTopEnabledAsync(0); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetTopEnabledAsync_WithEmptyDatabase_ReturnsEmptyList() + { + // Act + var result = await _repository.GetTopEnabledAsync(5); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetTopEnabledAsync_RespectsTakeCount_WhenMoreKeysExist() + { + // Arrange + var keyGroup = await CreateTestKeyGroup("Test Group"); + + // Create 10 enabled keys + for (int i = 1; i <= 10; i++) + { + await CreateTestKey($"Key {i:D2}", $"hash{i}", keyGroup.Id, isEnabled: true); + } + + // Act + var result = await _repository.GetTopEnabledAsync(5); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(5); + // Should return first 5 alphabetically + result.Select(k => k.KeyName).Should().BeEquivalentTo( + new[] { "Key 01", "Key 02", "Key 03", "Key 04", "Key 05" }); + } + + #endregion + + #region Helper Methods + + private async Task CreateTestKeyGroup(string groupName) + { + var keyGroup = new VirtualKeyGroup + { + GroupName = groupName, + Balance = 100.0m, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _context.VirtualKeyGroups.Add(keyGroup); + await _context.SaveChangesAsync(); + return keyGroup; + } + + private async Task CreateTestKey( + string keyName, + string keyHash, + int groupId, + bool isEnabled) + { + var key = new VirtualKey + { + KeyName = keyName, + KeyHash = keyHash, + IsEnabled = isEnabled, + VirtualKeyGroupId = groupId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + _context.VirtualKeys.Add(key); + await _context.SaveChangesAsync(); + return key; + } + + #endregion + + public void Dispose() + { + _context?.Dispose(); + } + } +} diff --git a/Tests/ConduitLLM.Tests/Configuration/Services/BatchSpendUpdateServiceTests.cs b/Tests/ConduitLLM.Tests/Configuration/Services/BatchSpendUpdateServiceTests.cs index b566a85d2..fcc5d4a13 100644 --- a/Tests/ConduitLLM.Tests/Configuration/Services/BatchSpendUpdateServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Configuration/Services/BatchSpendUpdateServiceTests.cs @@ -325,7 +325,7 @@ public async Task FlushPendingUpdates_WithMultipleKeys_ShouldCreateSingleTransac } [Fact] - public async Task QueueSpendUpdate_ShouldAccumulateSpendInRedis() + public async Task QueueSpendUpdateAsync_ShouldAccumulateSpendInRedis() { // Arrange var virtualKeyId = 1; @@ -362,10 +362,7 @@ public async Task QueueSpendUpdate_ShouldAccumulateSpendInRedis() .ReturnsAsync(true); // Act - _service.QueueSpendUpdate(virtualKeyId, cost); - - // Give the async task time to complete - await Task.Delay(100); + await _service.QueueSpendUpdateAsync(virtualKeyId, cost); // Assert _mockRedisDb.Verify(x => x.StringIncrementAsync( diff --git a/Tests/ConduitLLM.Tests/Configuration/Services/CacheConfigurationServiceTests.cs b/Tests/ConduitLLM.Tests/Configuration/Services/CacheConfigurationServiceTests.cs deleted file mode 100644 index fdf08bf05..000000000 --- a/Tests/ConduitLLM.Tests/Configuration/Services/CacheConfigurationServiceTests.cs +++ /dev/null @@ -1,404 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using MassTransit; -using Moq; -using ConduitLLM.Configuration; -using ConduitLLM.Configuration.Entities; -using ConduitLLM.Configuration.Events; -using ConduitLLM.Configuration.Models; -using ConduitLLM.Configuration.Services; - -namespace ConduitLLM.Tests.Configuration.Services -{ - public class CacheConfigurationServiceTests : IDisposable - { - private readonly ConduitDbContext _dbContext; - private readonly Mock _mockPublishEndpoint; - private readonly Mock _mockConfiguration; - private readonly Mock> _mockLogger; - private readonly CacheConfigurationService _service; - - public CacheConfigurationServiceTests() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - - _dbContext = new ConduitDbContext(options); - _mockPublishEndpoint = new Mock(); - _mockConfiguration = new Mock(); - _mockLogger = new Mock>(); - - _service = new CacheConfigurationService( - _dbContext, - _mockPublishEndpoint.Object, - _mockConfiguration.Object, - _mockLogger.Object); - } - - [Fact] - public async Task GetConfigurationAsync_ExistingConfiguration_ReturnsConfig() - { - // Arrange - var entity = new CacheConfiguration - { - Region = CacheRegions.VirtualKeys, - Enabled = true, - DefaultTtlSeconds = 1800, - Priority = 100, - IsActive = true - }; - _dbContext.CacheConfigurations.Add(entity); - await _dbContext.SaveChangesAsync(); - - // Act - var result = await _service.GetConfigurationAsync(CacheRegions.VirtualKeys); - - // Assert - Assert.NotNull(result); - Assert.Equal(CacheRegions.VirtualKeys, result.Region); - Assert.True(result.Enabled); - Assert.Equal(TimeSpan.FromSeconds(1800), result.DefaultTTL); - Assert.Equal(100, result.Priority); - } - - [Fact] - public async Task GetConfigurationAsync_NonExistentRegion_ReturnsNull() - { - // Act - var result = await _service.GetConfigurationAsync(CacheRegions.ModelMetadata); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetConfigurationAsync_LoadsFromConfiguration_WhenNotInDatabase() - { - // Arrange - use ConfigurationBuilder to create a real configuration section - var configData = new Dictionary - { - [$"Cache:Regions:{CacheRegions.RateLimits}:Enabled"] = "true", - [$"Cache:Regions:{CacheRegions.RateLimits}:Priority"] = "75", - [$"Cache:Regions:{CacheRegions.RateLimits}:DefaultTtlSeconds"] = "900" - }; - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(configData) - .Build(); - - _mockConfiguration.Setup(x => x.GetSection($"Cache:Regions:{CacheRegions.RateLimits}")) - .Returns(configuration.GetSection($"Cache:Regions:{CacheRegions.RateLimits}")); - - // Act - var result = await _service.GetConfigurationAsync(CacheRegions.RateLimits); - - // Assert - Assert.NotNull(result); - Assert.Equal(CacheRegions.RateLimits, result.Region); - Assert.True(result.Enabled); - Assert.Equal(75, result.Priority); - Assert.Equal(TimeSpan.FromSeconds(900), result.DefaultTTL); - } - - [Fact] - public async Task CreateConfigurationAsync_ValidConfig_CreatesSuccessfully() - { - // Arrange - var config = new CacheRegionConfig - { - Region = CacheRegions.ModelCosts, - Enabled = true, - DefaultTTL = TimeSpan.FromMinutes(60), - Priority = 50 - }; - - // Act - var result = await _service.CreateConfigurationAsync( - CacheRegions.ModelCosts, - config, - "test-user"); - - // Assert - Assert.NotNull(result); - Assert.Equal(CacheRegions.ModelCosts, result.Region); - Assert.True(result.Enabled); - - var savedEntity = await _dbContext.CacheConfigurations - .FirstOrDefaultAsync(c => c.Region == CacheRegions.ModelCosts); - Assert.NotNull(savedEntity); - Assert.True(savedEntity.IsActive); - Assert.Equal("test-user", savedEntity.CreatedBy); - - _mockPublishEndpoint.Verify(x => x.Publish( - It.Is(e => - e.Region == CacheRegions.ModelCosts && - e.Action == "Created"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task CreateConfigurationAsync_ExistingActiveConfig_ThrowsException() - { - // Arrange - var entity = new CacheConfiguration - { - Region = CacheRegions.AuthTokens, - IsActive = true - }; - _dbContext.CacheConfigurations.Add(entity); - await _dbContext.SaveChangesAsync(); - - var config = new CacheRegionConfig - { - Region = CacheRegions.AuthTokens, - Enabled = true - }; - - // Act & Assert - await Assert.ThrowsAsync(() => - _service.CreateConfigurationAsync(CacheRegions.AuthTokens, config, "test-user")); - } - - [Fact] - public async Task UpdateConfigurationAsync_ValidConfig_UpdatesSuccessfully() - { - // Arrange - var entity = new CacheConfiguration - { - Region = CacheRegions.ProviderHealth, - Enabled = true, - DefaultTtlSeconds = 300, - IsActive = true - }; - _dbContext.CacheConfigurations.Add(entity); - await _dbContext.SaveChangesAsync(); - - var newConfig = new CacheRegionConfig - { - Region = CacheRegions.ProviderHealth, - Enabled = false, - DefaultTTL = TimeSpan.FromMinutes(10) - }; - - // Act - var result = await _service.UpdateConfigurationAsync( - CacheRegions.ProviderHealth, - newConfig, - "test-user", - "Disabling cache for maintenance"); - - // Assert - Assert.NotNull(result); - Assert.False(result.Enabled); - Assert.Equal(TimeSpan.FromMinutes(10), result.DefaultTTL); - - var audit = await _dbContext.CacheConfigurationAudits - .FirstOrDefaultAsync(a => a.Region == CacheRegions.ProviderHealth); - Assert.NotNull(audit); - Assert.Equal("Updated", audit.Action); - Assert.Equal("test-user", audit.ChangedBy); - Assert.Equal("Disabling cache for maintenance", audit.Reason); - Assert.True(audit.Success); - - _mockPublishEndpoint.Verify(x => x.Publish( - It.Is(e => - e.Region == CacheRegions.ProviderHealth && - e.Action == "Updated"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task DeleteConfigurationAsync_ExistingConfig_SoftDeletesSuccessfully() - { - // Arrange - var entity = new CacheConfiguration - { - Region = CacheRegions.IpFilters, - IsActive = true - }; - _dbContext.CacheConfigurations.Add(entity); - await _dbContext.SaveChangesAsync(); - - // Act - var result = await _service.DeleteConfigurationAsync( - CacheRegions.IpFilters, - "test-user", - "No longer needed"); - - // Assert - Assert.True(result); - - var deletedEntity = await _dbContext.CacheConfigurations - .IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.Region == CacheRegions.IpFilters); - Assert.NotNull(deletedEntity); - Assert.False(deletedEntity.IsActive); - - _mockPublishEndpoint.Verify(x => x.Publish( - It.Is(e => - e.Region == CacheRegions.IpFilters && - e.Action == "Deleted"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ValidateConfigurationAsync_ValidConfig_ReturnsValid() - { - // Arrange - var config = new CacheRegionConfig - { - DefaultTTL = TimeSpan.FromMinutes(5), - MaxTTL = TimeSpan.FromMinutes(30), - MaxEntries = 1000, - Priority = 50 - }; - - // Act - var result = await _service.ValidateConfigurationAsync(config); - - // Assert - Assert.True(result.IsValid); - Assert.Empty(result.Errors); - } - - [Fact] - public async Task ValidateConfigurationAsync_InvalidConfig_ReturnsErrors() - { - // Arrange - var config = new CacheRegionConfig - { - DefaultTTL = TimeSpan.FromMinutes(-5), - MaxTTL = TimeSpan.FromMinutes(10), - MaxEntries = -100, - Priority = 150 - }; - - // Act - var result = await _service.ValidateConfigurationAsync(config); - - // Assert - Assert.False(result.IsValid); - Assert.Contains("DefaultTTL cannot be negative", result.Errors); - Assert.Contains("MaxEntries must be greater than 0", result.Errors); - Assert.Contains("Priority must be between 0 and 100", result.Errors); - } - - [Fact] - public async Task GetAuditHistoryAsync_ReturnsAuditEntries() - { - // Arrange - var audits = new[] - { - new CacheConfigurationAudit - { - Region = CacheRegions.GlobalSettings, - Action = "Created", - ChangedBy = "user1", - ChangedAt = DateTime.UtcNow.AddHours(-2) - }, - new CacheConfigurationAudit - { - Region = CacheRegions.GlobalSettings, - Action = "Updated", - ChangedBy = "user2", - ChangedAt = DateTime.UtcNow.AddHours(-1) - } - }; - _dbContext.CacheConfigurationAudits.AddRange(audits); - await _dbContext.SaveChangesAsync(); - - // Act - var result = await _service.GetAuditHistoryAsync(CacheRegions.GlobalSettings); - - // Assert - var auditList = result.ToList(); - Assert.Equal(2, auditList.Count); - Assert.Equal("Updated", auditList[0].Action); // Most recent first - Assert.Equal("Created", auditList[1].Action); - } - - [Fact] - public async Task RollbackConfigurationAsync_ValidAudit_RollsBackSuccessfully() - { - // Arrange - var oldConfig = new CacheRegionConfig - { - Region = CacheRegions.AsyncTasks, - Enabled = true, - DefaultTTL = TimeSpan.FromMinutes(15) - }; - - var audit = new CacheConfigurationAudit - { - Region = CacheRegions.AsyncTasks, - Action = "Updated", - OldConfigJson = System.Text.Json.JsonSerializer.Serialize(oldConfig), - ChangedBy = "user1" - }; - _dbContext.CacheConfigurationAudits.Add(audit); - - var currentEntity = new CacheConfiguration - { - Region = CacheRegions.AsyncTasks, - Enabled = false, - IsActive = true - }; - _dbContext.CacheConfigurations.Add(currentEntity); - await _dbContext.SaveChangesAsync(); - - // Act - var result = await _service.RollbackConfigurationAsync( - CacheRegions.AsyncTasks, - audit.Id, - "rollback-user"); - - // Assert - Assert.NotNull(result); - Assert.True(result.Enabled); - Assert.Equal(TimeSpan.FromMinutes(15), result.DefaultTTL); - - _mockPublishEndpoint.Verify(x => x.Publish( - It.Is(e => - e.Region == CacheRegions.AsyncTasks && - e.Action == "RolledBack" && - e.IsRollback == true), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ApplyEnvironmentConfigurationsAsync_AppliesEnvironmentVariables() - { - // Arrange - Environment.SetEnvironmentVariable("CONDUIT_CACHE_EMBEDDINGS_ENABLED", "false"); - Environment.SetEnvironmentVariable("CONDUIT_CACHE_EMBEDDINGS_TTL", "7200"); - - try - { - // Act - await _service.ApplyEnvironmentConfigurationsAsync(); - - // Assert - var config = await _dbContext.CacheConfigurations - .FirstOrDefaultAsync(c => c.Region == CacheRegions.Embeddings); - - Assert.NotNull(config); - Assert.False(config.Enabled); - Assert.Equal(7200, config.DefaultTtlSeconds); - Assert.Equal("System", config.CreatedBy); - } - finally - { - // Cleanup - Environment.SetEnvironmentVariable("CONDUIT_CACHE_EMBEDDINGS_ENABLED", null); - Environment.SetEnvironmentVariable("CONDUIT_CACHE_EMBEDDINGS_TTL", null); - } - } - - public void Dispose() - { - _dbContext?.Dispose(); - } - } -} \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Configuration/Services/GlobalSettingsCacheServiceTests.cs b/Tests/ConduitLLM.Tests/Configuration/Services/GlobalSettingsCacheServiceTests.cs index bd75408ff..1e61b670a 100644 --- a/Tests/ConduitLLM.Tests/Configuration/Services/GlobalSettingsCacheServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Configuration/Services/GlobalSettingsCacheServiceTests.cs @@ -59,7 +59,7 @@ public async Task StartAsync_WithAvailableSettings_LoadsAllSettingsIntoCache() new() { Id = 3, Key = "setting3", Value = "value3" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); // Act await _service.StartAsync(CancellationToken.None); @@ -74,7 +74,7 @@ public async Task StartAsync_WithAvailableSettings_LoadsAllSettingsIntoCache() public async Task StartAsync_WithNoSettings_LoadsEmptyCache() { // Arrange - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(new List()); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(new List()); // Act await _service.StartAsync(CancellationToken.None); @@ -93,7 +93,7 @@ public async Task StartAsync_LogsStartupInformation() new() { Id = 1, Key = "setting1", Value = "value1" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); // Act await _service.StartAsync(CancellationToken.None); @@ -125,7 +125,7 @@ public async Task StartAsync_WhenDatabaseThrowsException_DoesNotThrowButLogsErro { // Arrange var exception = new InvalidOperationException("Database unavailable"); - _mockRepository.Setup(x => x.GetAllAsync()).ThrowsAsync(exception); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ThrowsAsync(exception); // Act await _service.StartAsync(CancellationToken.None); @@ -149,7 +149,7 @@ public async Task StartAsync_WhenCancellationRequested_StopsLoadingSettings() .Select(i => new GlobalSetting { Id = i, Key = $"setting{i}", Value = $"value{i}" }) .ToList(); - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); var cts = new CancellationTokenSource(); cts.Cancel(); @@ -176,7 +176,7 @@ public async Task StopAsync_ClearsCacheAndCompletesSuccessfully() new() { Id = 1, Key = "setting1", Value = "value1" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Verify cache has data @@ -221,7 +221,7 @@ public async Task GetMaxAgenticIterationsAsync_WithValidSetting_ReturnsValue() new() { Id = 1, Key = "Agentic.MaxIterations", Value = "10" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act @@ -235,7 +235,7 @@ public async Task GetMaxAgenticIterationsAsync_WithValidSetting_ReturnsValue() public async Task GetMaxAgenticIterationsAsync_WhenSettingNotFound_ReturnsDefault() { // Arrange - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(new List()); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(new List()); await _service.StartAsync(CancellationToken.None); // Act @@ -254,7 +254,7 @@ public async Task GetMaxAgenticIterationsAsync_WithInvalidValue_ReturnsDefaultAn new() { Id = 1, Key = "Agentic.MaxIterations", Value = "not_a_number" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act @@ -266,7 +266,7 @@ public async Task GetMaxAgenticIterationsAsync_WithInvalidValue_ReturnsDefaultAn x => x.Log( LogLevel.Warning, It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains("Failed to parse max agentic iterations")), + It.Is((o, t) => o.ToString()!.Contains("Failed to parse Max agentic iterations")), It.IsAny(), It.IsAny>()), Times.Once); @@ -287,7 +287,7 @@ public async Task GetMaxAgenticIterationsAsync_ClampsToValidRange(string value, new() { Id = 1, Key = "Agentic.MaxIterations", Value = value } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act @@ -310,7 +310,7 @@ public async Task GetMinAgenticIterationsAsync_WithValidSetting_ReturnsValue() new() { Id = 1, Key = "Agentic.MinIterations", Value = "3" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act @@ -324,7 +324,7 @@ public async Task GetMinAgenticIterationsAsync_WithValidSetting_ReturnsValue() public async Task GetMinAgenticIterationsAsync_WhenSettingNotFound_ReturnsDefault() { // Arrange - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(new List()); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(new List()); await _service.StartAsync(CancellationToken.None); // Act @@ -363,7 +363,7 @@ public async Task GetDefaultAgenticModeEnabledAsync_WithVariousValidFormats_Pars new() { Id = 1, Key = "Agentic.DefaultEnabled", Value = value } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act @@ -377,7 +377,7 @@ public async Task GetDefaultAgenticModeEnabledAsync_WithVariousValidFormats_Pars public async Task GetDefaultAgenticModeEnabledAsync_WhenSettingNotFound_ReturnsDefault() { // Arrange - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(new List()); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(new List()); await _service.StartAsync(CancellationToken.None); // Act @@ -396,7 +396,7 @@ public async Task GetDefaultAgenticModeEnabledAsync_WithInvalidValue_ReturnsDefa new() { Id = 1, Key = "Agentic.DefaultEnabled", Value = "maybe" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act @@ -408,7 +408,7 @@ public async Task GetDefaultAgenticModeEnabledAsync_WithInvalidValue_ReturnsDefa x => x.Log( LogLevel.Warning, It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains("Failed to parse default agentic enabled")), + It.Is((o, t) => o.ToString()!.Contains("Failed to parse Default agentic enabled")), It.IsAny(), It.IsAny>()), Times.Once); @@ -427,7 +427,7 @@ public async Task InvalidateSettingAsync_WithExistingSetting_RemovesFromCacheAnd new() { Id = 1, Key = "test_setting", Value = "old_value" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); var updatedSetting = new GlobalSetting { Id = 1, Key = "test_setting", Value = "new_value" }; @@ -454,7 +454,7 @@ public async Task InvalidateSettingAsync_WithExistingSetting_RemovesFromCacheAnd public async Task InvalidateSettingAsync_WithNonExistentSetting_LogsDebugMessage() { // Arrange - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(new List()); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(new List()); await _service.StartAsync(CancellationToken.None); // Act @@ -480,7 +480,7 @@ public async Task InvalidateSettingAsync_WithNullOrEmptyKey_DoesNothing() new() { Id = 1, Key = "test", Value = "value" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act @@ -501,7 +501,7 @@ public async Task InvalidateSettingAsync_WhenRepositoryThrows_LogsErrorButDoesNo new() { Id = 1, Key = "failing_setting", Value = "value" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); var exception = new InvalidOperationException("Database error"); @@ -530,7 +530,7 @@ public async Task InvalidateSettingAsync_UpdatesInvalidationStatistics() new() { Id = 1, Key = "stat_test", Value = "value" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); var statsBefore = await _service.GetCacheStatsAsync(); @@ -561,7 +561,7 @@ public async Task ReloadAllSettingsAsync_ClearsAndReloadsCache() new() { Id = 1, Key = "setting1", Value = "value1" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(initialSettings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(initialSettings); await _service.StartAsync(CancellationToken.None); var newSettings = new List @@ -570,7 +570,7 @@ public async Task ReloadAllSettingsAsync_ClearsAndReloadsCache() new() { Id = 3, Key = "setting3", Value = "value3" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(newSettings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(newSettings); // Act await _service.ReloadAllSettingsAsync(); @@ -588,7 +588,7 @@ public async Task ReloadAllSettingsAsync_ClearsAndReloadsCache() public async Task ReloadAllSettingsAsync_WhenRepositoryThrows_RethrowsException() { // Arrange - _mockRepository.Setup(x => x.GetAllAsync()) + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("Database error")); // Act & Assert @@ -610,7 +610,7 @@ public async Task GetCacheStatsAsync_ReturnsCorrectStatistics() new() { Id = 2, Key = "test_setting", Value = "value" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Generate some cache hits and misses @@ -639,7 +639,7 @@ public async Task GetCacheStatsAsync_CalculatesHitRateCorrectly() new() { Id = 1, Key = "Agentic.MaxIterations", Value = "10" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Generate 3 hits and 1 miss @@ -668,7 +668,7 @@ public async Task ConcurrentInvalidations_AreHandledSafely() .Select(i => new GlobalSetting { Id = i, Key = $"setting{i}", Value = $"value{i}" }) .ToList(); - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); _mockRepository.Setup(x => x.GetByKeyAsync(It.IsAny(), It.IsAny())) @@ -697,7 +697,7 @@ public async Task ConcurrentReads_AreHandledSafely() new() { Id = 3, Key = "Agentic.DefaultEnabled", Value = "true" } }; - _mockRepository.Setup(x => x.GetAllAsync()).ReturnsAsync(settings); + _mockRepository.Setup(x => x.GetAllUnboundedAsync(It.IsAny())).ReturnsAsync(settings); await _service.StartAsync(CancellationToken.None); // Act - Read settings concurrently diff --git a/Tests/ConduitLLM.Tests/Core/ContextTokenLimitRetrievalTests.cs b/Tests/ConduitLLM.Tests/Core/ContextTokenLimitRetrievalTests.cs index d84a7d160..dcf381bdc 100644 --- a/Tests/ConduitLLM.Tests/Core/ContextTokenLimitRetrievalTests.cs +++ b/Tests/ConduitLLM.Tests/Core/ContextTokenLimitRetrievalTests.cs @@ -77,8 +77,8 @@ public async Task Conduit_Should_Use_MaxInputTokens_For_Context_Management() mappingServiceMock.Setup(x => x.GetMappingByModelAliasAsync(modelAlias)) .ReturnsAsync(mapping); - clientFactoryMock.Setup(x => x.GetClient(It.IsAny())) - .Returns(clientMock.Object); + clientFactoryMock.Setup(x => x.GetClientAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(clientMock.Object); var request = new ChatCompletionRequest { @@ -183,8 +183,8 @@ public async Task Conduit_Should_Use_Provider_Override_When_Available() mappingServiceMock.Setup(x => x.GetMappingByModelAliasAsync(modelAlias)) .ReturnsAsync(mapping); - clientFactoryMock.Setup(x => x.GetClient(It.IsAny())) - .Returns(clientMock.Object); + clientFactoryMock.Setup(x => x.GetClientAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(clientMock.Object); var request = new ChatCompletionRequest { @@ -287,8 +287,8 @@ public async Task Conduit_Should_Not_Apply_Context_Management_When_No_Limits_Ava mappingServiceMock.Setup(x => x.GetMappingByModelAliasAsync(modelAlias)) .ReturnsAsync(mapping); - clientFactoryMock.Setup(x => x.GetClient(It.IsAny())) - .Returns(clientMock.Object); + clientFactoryMock.Setup(x => x.GetClientAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(clientMock.Object); var request = new ChatCompletionRequest { diff --git a/Tests/ConduitLLM.Tests/Core/Decorators/PromptCachingLLMClientTests.cs b/Tests/ConduitLLM.Tests/Core/Decorators/PromptCachingLLMClientTests.cs new file mode 100644 index 000000000..a3928e314 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Core/Decorators/PromptCachingLLMClientTests.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Decorators; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace ConduitLLM.Tests.Core.Decorators; + +public class PromptCachingLLMClientTests +{ + private readonly Mock _innerClient = new(); + private readonly Mock _settingsService = new(); + private readonly Mock> _logger = new(); + + private PromptCachingLLMClient CreateSut() + => new(_innerClient.Object, _settingsService.Object, _logger.Object); + + private static ChatCompletionRequest CreateRequest() + => new() + { + Model = "test-model", + Messages = new List + { + new() { Role = "system", Content = "You are helpful." }, + new() { Role = "user", Content = "Hello" } + } + }; + + [Fact] + public async Task CreateChatCompletion_WhenDisabled_PassesThroughUnmodified() + { + // Arrange + var config = new PromptCachingConfig { AutoInjectEnabled = false }; + _settingsService + .Setup(s => s.GetSettingValueAsync(PromptCachingLLMClient.SettingsKey)) + .ReturnsAsync(JsonSerializer.Serialize(config)); + + var request = CreateRequest(); + var expectedResponse = new ChatCompletionResponse + { + Id = "test", + Model = "test-model", + Object = "chat.completion", + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Choices = new List() + }; + _innerClient + .Setup(c => c.CreateChatCompletionAsync(request, null, default)) + .ReturnsAsync(expectedResponse); + + var sut = CreateSut(); + + // Act + var result = await sut.CreateChatCompletionAsync(request); + + // Assert + result.Should().BeSameAs(expectedResponse); + request.Messages[0].Content.Should().Be("You are helpful."); + } + + [Fact] + public async Task CreateChatCompletion_WhenEnabled_InjectsCacheControl() + { + // Arrange + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system" } + } + }; + _settingsService + .Setup(s => s.GetSettingValueAsync(PromptCachingLLMClient.SettingsKey)) + .ReturnsAsync(JsonSerializer.Serialize(config)); + + var request = CreateRequest(); + _innerClient + .Setup(c => c.CreateChatCompletionAsync(It.IsAny(), null, default)) + .ReturnsAsync(new ChatCompletionResponse + { + Id = "test", + Model = "test-model", + Object = "chat.completion", + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Choices = new List() + }); + + var sut = CreateSut(); + + // Act + await sut.CreateChatCompletionAsync(request); + + // Assert โ€” system message should have been converted to content array with cache_control + request.Messages[0].Content.Should().BeAssignableTo>(); + _innerClient.Verify(c => c.CreateChatCompletionAsync(request, null, default), Times.Once); + } + + [Fact] + public async Task CreateChatCompletion_WhenSettingMissing_PassesThroughUnmodified() + { + // Arrange + _settingsService + .Setup(s => s.GetSettingValueAsync(PromptCachingLLMClient.SettingsKey)) + .ReturnsAsync((string?)null); + + var request = CreateRequest(); + _innerClient + .Setup(c => c.CreateChatCompletionAsync(request, null, default)) + .ReturnsAsync(new ChatCompletionResponse + { + Id = "test", + Model = "test-model", + Object = "chat.completion", + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Choices = new List() + }); + + var sut = CreateSut(); + + // Act + await sut.CreateChatCompletionAsync(request); + + // Assert + request.Messages[0].Content.Should().Be("You are helpful."); + } + + [Fact] + public async Task CreateChatCompletion_WhenSettingsThrows_ContinuesWithoutInjection() + { + // Arrange + _settingsService + .Setup(s => s.GetSettingValueAsync(PromptCachingLLMClient.SettingsKey)) + .ThrowsAsync(new InvalidOperationException("DB error")); + + var request = CreateRequest(); + _innerClient + .Setup(c => c.CreateChatCompletionAsync(request, null, default)) + .ReturnsAsync(new ChatCompletionResponse + { + Id = "test", + Model = "test-model", + Object = "chat.completion", + Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Choices = new List() + }); + + var sut = CreateSut(); + + // Act โ€” should NOT throw + var result = await sut.CreateChatCompletionAsync(request); + + // Assert โ€” request should be unmodified, inner client still called + request.Messages[0].Content.Should().Be("You are helpful."); + result.Should().NotBeNull(); + } + + [Fact] + public async Task StreamChatCompletion_WhenEnabled_InjectsCacheControl() + { + // Arrange + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system" } + } + }; + _settingsService + .Setup(s => s.GetSettingValueAsync(PromptCachingLLMClient.SettingsKey)) + .ReturnsAsync(JsonSerializer.Serialize(config)); + + var chunks = new List + { + new() { Id = "chunk-1", Choices = new List() } + }; + _innerClient + .Setup(c => c.StreamChatCompletionAsync(It.IsAny(), null, default)) + .Returns(chunks.ToAsyncEnumerable()); + + var request = CreateRequest(); + var sut = CreateSut(); + + // Act + var results = new List(); + await foreach (var chunk in sut.StreamChatCompletionAsync(request)) + { + results.Add(chunk); + } + + // Assert + results.Should().HaveCount(1); + request.Messages[0].Content.Should().BeAssignableTo>(); + } + + [Fact] + public async Task NonChatMethods_PassThrough() + { + // Arrange + _innerClient.Setup(c => c.ListModelsAsync(null, default)) + .ReturnsAsync(new List { "model-1" }); + + var sut = CreateSut(); + + // Act + var models = await sut.ListModelsAsync(); + + // Assert + models.Should().Contain("model-1"); + } +} diff --git a/Tests/ConduitLLM.Tests/Core/Events/ConnectionLimitExceededTests.cs b/Tests/ConduitLLM.Tests/Core/Events/ConnectionLimitExceededTests.cs index d9f50aa71..1a13378c0 100644 --- a/Tests/ConduitLLM.Tests/Core/Events/ConnectionLimitExceededTests.cs +++ b/Tests/ConduitLLM.Tests/Core/Events/ConnectionLimitExceededTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using ConduitLLM.Core.Events; namespace ConduitLLM.Tests.Core.Events @@ -32,8 +33,8 @@ public void InheritsFromDomainEvent() var eventRecord = new ConnectionLimitExceeded(); // Assert - Assert.IsAssignableFrom(eventRecord); - Assert.IsAssignableFrom(eventRecord); + eventRecord.Should().BeAssignableTo(); + eventRecord.Should().BeAssignableTo(); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Core/Exceptions/ExceptionToResponseMapperTests.cs b/Tests/ConduitLLM.Tests/Core/Exceptions/ExceptionToResponseMapperTests.cs new file mode 100644 index 000000000..9c7410a60 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Core/Exceptions/ExceptionToResponseMapperTests.cs @@ -0,0 +1,442 @@ +using System.Net; + +using ConduitLLM.Core.Exceptions; + +using FluentAssertions; + +using Microsoft.Extensions.Logging; + +namespace ConduitLLM.Tests.Core.Exceptions; + +/// +/// Unit tests for the class. +/// +[Trait("Category", "Unit")] +[Trait("Component", "ExceptionMapping")] +public class ExceptionToResponseMapperTests +{ + #region Standard .NET Exception Tests + + [Fact] + public void Map_ArgumentNullException_Returns400WithMissingParameter() + { + // Arrange + var exception = new ArgumentNullException("testParam"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(400); + result.ErrorCode.Should().Be("missing_parameter"); + result.ResponseMessage.Should().Be("Required parameter is missing"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Argument error"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + result.Param.Should().Be("testParam"); + } + + [Fact] + public void Map_ArgumentException_Returns400WithInvalidParameter() + { + // Arrange + var exception = new ArgumentException("Invalid argument value", "myParam"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(400); + result.ErrorCode.Should().Be("invalid_parameter"); + result.ResponseMessage.Should().Be("Invalid parameter value"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Argument error"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + result.Param.Should().Be("myParam"); + } + + [Fact] + public void Map_InvalidOperationException_Returns400WithInvalidOperation() + { + // Arrange + var exception = new InvalidOperationException("Cannot perform this operation"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(400); + result.ErrorCode.Should().Be("invalid_operation"); + result.ResponseMessage.Should().Be("The requested operation is not valid"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Invalid operation"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + result.Param.Should().BeNull(); + } + + [Fact] + public void Map_KeyNotFoundException_Returns404WithNotFound() + { + // Arrange + var exception = new KeyNotFoundException("Resource not found"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(404); + result.ErrorCode.Should().Be("not_found"); + result.ResponseMessage.Should().Be("The requested resource was not found"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Resource not found"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + } + + [Fact] + public void Map_UnauthorizedAccessException_Returns401WithUnauthorized() + { + // Arrange + var exception = new UnauthorizedAccessException(); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(401); + result.ErrorCode.Should().Be("unauthorized"); + result.ResponseMessage.Should().Be("Authentication required"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Unauthorized access attempt"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + } + + [Fact] + public void Map_TimeoutException_Returns408WithTimeout() + { + // Arrange + var exception = new TimeoutException("Operation timed out"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(408); + result.ErrorCode.Should().Be("timeout"); + result.ResponseMessage.Should().Be("Request timed out"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.OpenAIErrorType.Should().Be("timeout_error"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + } + + [Fact] + public void Map_NotImplementedException_Returns501WithNotImplemented() + { + // Arrange + var exception = new NotImplementedException("Not yet available"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(501); + result.ErrorCode.Should().Be("not_implemented"); + result.ResponseMessage.Should().Be("Feature not implemented"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.OpenAIErrorType.Should().Be("server_error"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + } + + [Fact] + public void Map_GenericException_Returns500WithInternalError() + { + // Arrange + var exception = new Exception("Unexpected error"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(500); + result.ErrorCode.Should().Be("internal_error"); + result.ResponseMessage.Should().Be("An unexpected error occurred"); + result.LogLevel.Should().Be(LogLevel.Error); + result.LogPrefix.Should().Be("Unexpected error"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + result.OpenAIErrorType.Should().Be("server_error"); + } + + [Fact] + public void Map_UnknownExceptionType_Returns500WithInternalError() + { + // Arrange + var exception = new InvalidProgramException("Program error"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(500); + result.ErrorCode.Should().Be("internal_error"); + result.LogLevel.Should().Be(LogLevel.Error); + result.OpenAIErrorType.Should().Be("server_error"); + } + + #endregion + + #region Custom Conduit Exception Tests + + [Fact] + public void Map_AuthorizationException_Returns403WithForbidden() + { + // Arrange + var exception = new AuthorizationException("Not authorized to access this resource"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(403); + result.ErrorCode.Should().Be("forbidden"); + result.ResponseMessage.Should().Be("Not authorized to access this resource"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Authorization denied"); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + } + + [Fact] + public void Map_ModelNotFoundException_Returns404WithModelNotFound() + { + // Arrange + var exception = new ModelNotFoundException("gpt-5"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(404); + result.ErrorCode.Should().Be("model_not_found"); + result.ResponseMessage.Should().Contain("gpt-5"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Model not found"); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + result.Param.Should().Be("model"); + } + + [Fact] + public void Map_InvalidRequestException_Returns400WithErrorCode() + { + // Arrange + var exception = new InvalidRequestException("Invalid request body", "invalid_json"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(400); + result.ErrorCode.Should().Be("invalid_json"); + result.ResponseMessage.Should().Be("Invalid request body"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Invalid request"); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + } + + [Fact] + public void Map_InvalidRequestException_WithParam_ReturnsParam() + { + // Arrange + var exception = new InvalidRequestException("Bad model value", "invalid_param", "model"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.Param.Should().Be("model"); + result.ErrorCode.Should().Be("invalid_param"); + } + + [Fact] + public void Map_InvalidRequestException_WithoutErrorCode_Returns400WithDefaultCode() + { + // Arrange + var exception = new InvalidRequestException("Invalid request body"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(400); + result.ErrorCode.Should().Be("invalid_request"); + result.ResponseMessage.Should().Be("Invalid request body"); + } + + [Fact] + public void Map_RequestTimeoutException_Returns408WithRequestTimeout() + { + // Arrange + var exception = new RequestTimeoutException("Request timed out after 30s"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(408); + result.ErrorCode.Should().Be("request_timeout"); + result.ResponseMessage.Should().Be("Request timed out after 30s"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("timeout_error"); + } + + [Fact] + public void Map_PayloadTooLargeException_Returns413WithPayloadTooLarge() + { + // Arrange + var exception = new PayloadTooLargeException("Payload too large", 10000, 5000); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(413); + result.ErrorCode.Should().Be("payload_too_large"); + result.ResponseMessage.Should().Be("Payload too large"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + } + + [Fact] + public void Map_RateLimitExceededException_Returns429WithRateLimitExceeded() + { + // Arrange + var exception = new RateLimitExceededException("Rate limit exceeded, try again later"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(429); + result.ErrorCode.Should().Be("rate_limit_exceeded"); + result.ResponseMessage.Should().Be("Rate limit exceeded, try again later"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Rate limit exceeded"); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("rate_limit_error"); + } + + [Fact] + public void Map_ServiceUnavailableException_Returns503WithServiceUnavailable() + { + // Arrange + var exception = new ServiceUnavailableException("Service temporarily unavailable"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(503); + result.ErrorCode.Should().Be("service_unavailable"); + result.ResponseMessage.Should().Be("Service temporarily unavailable"); + result.LogLevel.Should().Be(LogLevel.Warning); + result.LogPrefix.Should().Be("Service unavailable"); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("service_unavailable"); + } + + [Fact] + public void Map_LLMCommunicationException_WithStatusCode_ReturnsProviderStatus() + { + // Arrange + var exception = new LLMCommunicationException("Provider returned error", + HttpStatusCode.BadGateway, "Bad gateway response"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(502); + result.ErrorCode.Should().Be("provider_communication_error"); + result.ResponseMessage.Should().Be("Provider returned error"); + result.IncludeExceptionMessageInLog.Should().BeTrue(); + result.OpenAIErrorType.Should().Be("server_error"); + } + + [Fact] + public void Map_LLMCommunicationException_WithClientErrorStatus_ReturnsInvalidRequestType() + { + // Arrange + var exception = new LLMCommunicationException("Bad request to provider", + HttpStatusCode.BadRequest, null); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(400); + result.OpenAIErrorType.Should().Be("invalid_request_error"); + result.LogLevel.Should().Be(LogLevel.Warning); + } + + [Fact] + public void Map_LLMCommunicationException_WithoutStatusCode_Returns500() + { + // Arrange + var exception = new LLMCommunicationException("Unknown provider error"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(500); + result.ErrorCode.Should().Be("provider_communication_error"); + result.OpenAIErrorType.Should().Be("server_error"); + result.LogLevel.Should().Be(LogLevel.Error); + } + + [Fact] + public void Map_ConfigurationException_Returns500WithConfigurationError() + { + // Arrange + var exception = new ConfigurationException("Invalid configuration setting"); + + // Act + var result = ExceptionToResponseMapper.Map(exception); + + // Assert + result.StatusCode.Should().Be(500); + result.ErrorCode.Should().Be("configuration_error"); + result.ResponseMessage.Should().Be("A configuration error occurred"); + result.LogLevel.Should().Be(LogLevel.Error); + result.LogPrefix.Should().Be("Configuration error"); + result.IncludeExceptionMessageInLog.Should().BeFalse(); + result.OpenAIErrorType.Should().Be("server_error"); + } + + #endregion + + #region Exception Hierarchy Tests + + [Fact] + public void Map_ArgumentNullException_IsHandledBeforeArgumentException() + { + // ArgumentNullException derives from ArgumentException + // Verify the more specific type is matched first + var exception = new ArgumentNullException("param"); + + var result = ExceptionToResponseMapper.Map(exception); + + result.StatusCode.Should().Be(400); + result.ErrorCode.Should().Be("missing_parameter"); + result.Param.Should().Be("param"); + } + + #endregion +} diff --git a/Tests/ConduitLLM.Tests/Core/Fixtures/MediaTestFixtures.cs b/Tests/ConduitLLM.Tests/Core/Fixtures/MediaTestFixtures.cs index 0e62444ba..147b02d56 100644 --- a/Tests/ConduitLLM.Tests/Core/Fixtures/MediaTestFixtures.cs +++ b/Tests/ConduitLLM.Tests/Core/Fixtures/MediaTestFixtures.cs @@ -105,8 +105,8 @@ public static Mock CreateMockMediaRecordRepository() { var mock = new Mock(); - mock.Setup(x => x.CreateAsync(It.IsAny())) - .ReturnsAsync((MediaRecord record) => record); + mock.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((MediaRecord record, CancellationToken _) => record.Id == Guid.Empty ? Guid.NewGuid() : record.Id); mock.Setup(x => x.GetByStorageKeyAsync(It.IsAny())) .ReturnsAsync((string key) => new MediaRecordBuilder() diff --git a/Tests/ConduitLLM.Tests/Core/Middleware/CorrelationIdMiddlewareTests.cs b/Tests/ConduitLLM.Tests/Core/Middleware/CorrelationIdMiddlewareTests.cs new file mode 100644 index 000000000..505c42005 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Core/Middleware/CorrelationIdMiddlewareTests.cs @@ -0,0 +1,388 @@ +using System.Diagnostics; + +using ConduitLLM.Core.Middleware; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; + +using Moq; + +namespace ConduitLLM.Tests.Core.Middleware +{ + [Trait("Category", "Unit")] + [Trait("Component", "Core")] + public class CorrelationIdMiddlewareTests + { + private readonly Mock> _mockLogger; + + public CorrelationIdMiddlewareTests() + { + _mockLogger = new Mock>(); + } + + [Fact] + public async Task GeneratesNewCorrelationId_WhenNoHeadersPresent() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + string? capturedTraceId = null; + + RequestDelegate next = ctx => + { + capturedTraceId = ctx.TraceIdentifier; + return Task.CompletedTask; + }; + + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.NotNull(capturedTraceId); + Assert.True(Guid.TryParse(capturedTraceId, out _), "Expected a valid GUID"); + } + + [Fact] + public async Task ExtractsCorrelationId_FromXCorrelationIDHeader() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["X-Correlation-ID"] = "abc-123"; + string? capturedTraceId = null; + + RequestDelegate next = ctx => + { + capturedTraceId = ctx.TraceIdentifier; + return Task.CompletedTask; + }; + + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal("abc-123", capturedTraceId); + } + + [Fact] + public async Task ExtractsCorrelationId_FromXRequestIDHeader() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["X-Request-ID"] = "req-456"; + string? capturedTraceId = null; + + RequestDelegate next = ctx => + { + capturedTraceId = ctx.TraceIdentifier; + return Task.CompletedTask; + }; + + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal("req-456", capturedTraceId); + } + + [Fact] + public async Task ExtractsCorrelationId_FromXTraceIDHeader() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["X-Trace-ID"] = "trace-789"; + string? capturedTraceId = null; + + RequestDelegate next = ctx => + { + capturedTraceId = ctx.TraceIdentifier; + return Task.CompletedTask; + }; + + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal("trace-789", capturedTraceId); + } + + [Fact] + public async Task ExtractsTraceId_FromTraceparentHeader() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["traceparent"] = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + string? capturedTraceId = null; + + RequestDelegate next = ctx => + { + capturedTraceId = ctx.TraceIdentifier; + return Task.CompletedTask; + }; + + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal("4bf92f3577b34da6a3ce929d0e0e4736", capturedTraceId); + } + + [Fact] + public async Task SetsContextItems_WithCorrelationId() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["X-Correlation-ID"] = "test-corr-id"; + object? capturedItemValue = null; + + RequestDelegate next = ctx => + { + ctx.Items.TryGetValue(CorrelationIdOptions.CorrelationIdItemsKey, out capturedItemValue); + return Task.CompletedTask; + }; + + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal("test-corr-id", capturedItemValue); + } + + [Fact] + public async Task AddsResponseHeader_WhenIncludeInResponseTrue() + { + // Arrange + // DefaultHttpContext doesn't fire OnStarting callbacks automatically, + // so we use a custom IHttpResponseFeature that captures and invokes them. + var onStartingCallbacks = new List<(Func callback, object state)>(); + + var features = new FeatureCollection(); + features.Set(new TestResponseFeature(onStartingCallbacks)); + features.Set(new HttpRequestFeature()); + var context = new DefaultHttpContext(features); + context.Request.Headers["X-Correlation-ID"] = "resp-header-test"; + + RequestDelegate next = _ => Task.CompletedTask; + + var options = new CorrelationIdOptions { IncludeInResponse = true }; + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object, options); + + // Act + await middleware.InvokeAsync(context); + + // Fire the registered OnStarting callbacks (simulating response start) + foreach (var (callback, state) in onStartingCallbacks) + { + await callback(state); + } + + // Assert + Assert.Equal("resp-header-test", context.Response.Headers["X-Correlation-ID"]); + } + + [Fact] + public async Task OmitsResponseHeader_WhenIncludeInResponseFalse() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["X-Correlation-ID"] = "no-resp-header"; + + RequestDelegate next = ctx => Task.CompletedTask; + + var options = new CorrelationIdOptions { IncludeInResponse = false }; + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object, options); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.False(context.Response.Headers.ContainsKey("X-Correlation-ID")); + } + + [Fact] + public async Task UsesShortIds_WhenConfigured() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + string? capturedTraceId = null; + + RequestDelegate next = ctx => + { + capturedTraceId = ctx.TraceIdentifier; + return Task.CompletedTask; + }; + + var options = new CorrelationIdOptions { UseShortIds = true }; + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object, options); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.NotNull(capturedTraceId); + Assert.Equal(8, capturedTraceId.Length); + } + + [Fact] + public void GetCorrelationId_ReturnsFromItems() + { + // Arrange + var context = new DefaultHttpContext(); + context.Items[CorrelationIdOptions.CorrelationIdItemsKey] = "items-corr-id"; + context.TraceIdentifier = "trace-id-fallback"; + + // Act + var result = context.GetCorrelationId(); + + // Assert + Assert.Equal("items-corr-id", result); + } + + [Fact] + public void GetCorrelationId_FallsBackToTraceIdentifier() + { + // Arrange + var context = new DefaultHttpContext(); + context.TraceIdentifier = "trace-id-fallback"; + + // Act + var result = context.GetCorrelationId(); + + // Assert + Assert.Equal("trace-id-fallback", result); + } + + [Fact] + public async Task BeginsLoggingScope_WithCorrelationId() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["X-Correlation-ID"] = "scope-test-id"; + + Dictionary? capturedScope = null; + _mockLogger + .Setup(x => x.BeginScope(It.IsAny())) + .Callback(state => + { + if (state is Dictionary dict) + { + capturedScope = new Dictionary(dict); + } + }) + .Returns(Mock.Of()); + + RequestDelegate next = _ => Task.CompletedTask; + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.NotNull(capturedScope); + Assert.True(capturedScope.ContainsKey("CorrelationId")); + Assert.Equal("scope-test-id", capturedScope["CorrelationId"]); + } + + [Fact] + public async Task SetsActivityBaggageAndTag_WhenActivityExists() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers["X-Correlation-ID"] = "activity-test-id"; + + RequestDelegate next = _ => Task.CompletedTask; + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Create an Activity so the middleware can set baggage/tags + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + + using var source = new ActivitySource("test"); + using var activity = source.StartActivity("test-operation"); + Assert.NotNull(activity); // Ensure activity was created + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal("activity-test-id", activity.GetBaggageItem("correlation.id")); + + var tag = activity.Tags.FirstOrDefault(t => t.Key == "correlation.id"); + Assert.Equal("activity-test-id", tag.Value); + } + + [Fact] + public async Task CallsNextDelegate() + { + // Arrange + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + var nextCalled = false; + + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = new CorrelationIdMiddleware(next, _mockLogger.Object); + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.True(nextCalled); + } + } + + /// + /// Test helper that captures OnStarting callbacks so they can be manually invoked. + /// + internal class TestResponseFeature : IHttpResponseFeature + { + private readonly List<(Func callback, object state)> _onStartingCallbacks; + + public TestResponseFeature(List<(Func, object)> onStartingCallbacks) + { + _onStartingCallbacks = onStartingCallbacks; + } + + public int StatusCode { get; set; } = 200; + public string? ReasonPhrase { get; set; } + public IHeaderDictionary Headers { get; set; } = new HeaderDictionary(); + public Stream Body { get; set; } = new MemoryStream(); + public bool HasStarted => false; + + public void OnStarting(Func callback, object state) + { + _onStartingCallbacks.Add((callback, state)); + } + + public void OnCompleted(Func callback, object state) { } + } +} diff --git a/Tests/ConduitLLM.Tests/Core/Services/CacheStatisticsPerformanceBenchmarks.cs b/Tests/ConduitLLM.Tests/Core/Services/CacheStatisticsPerformanceBenchmarks.cs index ecd539605..ece66d5ef 100644 --- a/Tests/ConduitLLM.Tests/Core/Services/CacheStatisticsPerformanceBenchmarks.cs +++ b/Tests/ConduitLLM.Tests/Core/Services/CacheStatisticsPerformanceBenchmarks.cs @@ -270,8 +270,8 @@ public async Task BatchOperations_ImprovedThroughput() ? (double)batchStopwatch.ElapsedMilliseconds / individualStopwatch.ElapsedMilliseconds : 1.0; - ratio.Should().BeLessThanOrEqualTo(2.0, - $"Batch operations should not take more than 2x the time of individual operations. " + + ratio.Should().BeLessThanOrEqualTo(4.0, + $"Batch operations should not take more than 4x the time of individual operations. " + $"Individual: {individualStopwatch.ElapsedMilliseconds}ms, Batch: {batchStopwatch.ElapsedMilliseconds}ms"); // Also ensure batch operations complete in reasonable time diff --git a/Tests/ConduitLLM.Tests/Core/Services/CachedModelCostServiceTests.cs b/Tests/ConduitLLM.Tests/Core/Services/CachedModelCostServiceTests.cs new file mode 100644 index 000000000..a79ba14cc --- /dev/null +++ b/Tests/ConduitLLM.Tests/Core/Services/CachedModelCostServiceTests.cs @@ -0,0 +1,343 @@ +using ConduitLLM.Configuration.Entities; +using ConduitLLM.Configuration.Interfaces; +using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; +using ConduitLLM.Core.Services; + +using Microsoft.Extensions.Logging; + +using Moq; + +namespace ConduitLLM.Tests.Core.Services +{ + /// + /// Unit tests for CachedModelCostService to verify caching decorator behavior + /// + public class CachedModelCostServiceTests + { + private readonly Mock _mockInnerService; + private readonly Mock _mockCacheManager; + private readonly Mock> _mockLogger; + private readonly CachedModelCostService _cachedService; + + private const string TestModelId = "gpt-4"; + private const int TestModelCostId = 42; + + private readonly ModelCost _testModelCost; + + public CachedModelCostServiceTests() + { + _mockInnerService = new Mock(); + _mockCacheManager = new Mock(); + _mockLogger = new Mock>(); + + _cachedService = new CachedModelCostService( + _mockInnerService.Object, + _mockCacheManager.Object, + _mockLogger.Object); + + _testModelCost = new ModelCost + { + Id = TestModelCostId, + CostName = "GPT-4 Cost", + InputCostPerMillionTokens = 30m, + OutputCostPerMillionTokens = 60m, + IsActive = true, + EffectiveDate = DateTime.UtcNow.AddDays(-1) + }; + } + + #region GetCostForModelAsync Tests + + [Fact] + public async Task GetCostForModelAsync_CacheHit_ReturnsFromCache() + { + // Arrange + _mockCacheManager + .Setup(x => x.GetAsync( + It.IsAny(), + CacheRegion.ModelCosts, + It.IsAny())) + .ReturnsAsync(_testModelCost); + + // Act + var result = await _cachedService.GetCostForModelAsync(TestModelId); + + // Assert + Assert.NotNull(result); + Assert.Equal(TestModelCostId, result.Id); + + // Should NOT call inner service + _mockInnerService.Verify(x => x.GetCostForModelAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetCostForModelAsync_CacheMiss_FallsBackToDatabase() + { + // Arrange + _mockCacheManager + .Setup(x => x.GetAsync( + It.IsAny(), + CacheRegion.ModelCosts, + It.IsAny())) + .ReturnsAsync((ModelCost?)null); + + _mockInnerService + .Setup(x => x.GetCostForModelAsync(TestModelId, It.IsAny())) + .ReturnsAsync(_testModelCost); + + // Act + var result = await _cachedService.GetCostForModelAsync(TestModelId); + + // Assert + Assert.NotNull(result); + Assert.Equal(TestModelCostId, result.Id); + + // Should call inner service + _mockInnerService.Verify(x => x.GetCostForModelAsync( + TestModelId, It.IsAny()), Times.Once); + + // Should cache the result + _mockCacheManager.Verify(x => x.SetAsync( + It.IsAny(), + _testModelCost, + CacheRegion.ModelCosts, + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetCostForModelAsync_CacheError_FallsBackToDatabase() + { + // Arrange + _mockCacheManager + .Setup(x => x.GetAsync( + It.IsAny(), + CacheRegion.ModelCosts, + It.IsAny())) + .ThrowsAsync(new Exception("Cache failure")); + + _mockInnerService + .Setup(x => x.GetCostForModelAsync(TestModelId, It.IsAny())) + .ReturnsAsync(_testModelCost); + + // Act + var result = await _cachedService.GetCostForModelAsync(TestModelId); + + // Assert + Assert.NotNull(result); + Assert.Equal(TestModelCostId, result.Id); + + // Should fall back to inner service + _mockInnerService.Verify(x => x.GetCostForModelAsync( + TestModelId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetCostForModelAsync_EmptyModelId_ThrowsArgumentException() + { + await Assert.ThrowsAsync(() => + _cachedService.GetCostForModelAsync("")); + + await Assert.ThrowsAsync(() => + _cachedService.GetCostForModelAsync(" ")); + } + + #endregion + + #region GetCostByIdAsync Tests + + [Fact] + public async Task GetCostByIdAsync_CacheHit_ReturnsFromCache() + { + // Arrange + _mockCacheManager + .Setup(x => x.GetAsync( + It.IsAny(), + CacheRegion.ModelCosts, + It.IsAny())) + .ReturnsAsync(_testModelCost); + + // Act + var result = await _cachedService.GetCostByIdAsync(TestModelCostId); + + // Assert + Assert.NotNull(result); + Assert.Equal(TestModelCostId, result.Id); + + _mockInnerService.Verify(x => x.GetCostByIdAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetCostByIdAsync_CacheMiss_QueriesDatabase() + { + // Arrange + _mockCacheManager + .Setup(x => x.GetAsync( + It.IsAny(), + CacheRegion.ModelCosts, + It.IsAny())) + .ReturnsAsync((ModelCost?)null); + + _mockInnerService + .Setup(x => x.GetCostByIdAsync(TestModelCostId, It.IsAny())) + .ReturnsAsync(_testModelCost); + + // Act + var result = await _cachedService.GetCostByIdAsync(TestModelCostId); + + // Assert + Assert.NotNull(result); + _mockInnerService.Verify(x => x.GetCostByIdAsync( + TestModelCostId, It.IsAny()), Times.Once); + } + + #endregion + + #region ListModelCostsAsync Tests + + [Fact] + public async Task ListModelCostsAsync_CacheHit_ReturnsFromCache() + { + // Arrange + var costs = new List { _testModelCost }; + + _mockCacheManager + .Setup(x => x.GetAsync>( + "modelcost:all", + CacheRegion.ModelCosts, + It.IsAny())) + .ReturnsAsync(costs); + + // Act + var result = await _cachedService.ListModelCostsAsync(); + + // Assert + Assert.Single(result); + _mockInnerService.Verify(x => x.ListModelCostsAsync( + It.IsAny()), Times.Never); + } + + #endregion + + #region Write Operation Tests + + [Fact] + public async Task AddModelCostAsync_InvalidatesCache() + { + // Arrange + _mockInnerService + .Setup(x => x.AddModelCostAsync(_testModelCost, It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _cachedService.AddModelCostAsync(_testModelCost); + + // Assert + _mockInnerService.Verify(x => x.AddModelCostAsync( + _testModelCost, It.IsAny()), Times.Once); + + _mockCacheManager.Verify(x => x.ClearRegionAsync( + CacheRegion.ModelCosts, It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateModelCostAsync_Success_InvalidatesCache() + { + // Arrange + _mockInnerService + .Setup(x => x.UpdateModelCostAsync(_testModelCost, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _cachedService.UpdateModelCostAsync(_testModelCost); + + // Assert + Assert.True(result); + _mockCacheManager.Verify(x => x.ClearRegionAsync( + CacheRegion.ModelCosts, It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateModelCostAsync_NotFound_DoesNotInvalidateCache() + { + // Arrange + _mockInnerService + .Setup(x => x.UpdateModelCostAsync(_testModelCost, It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _cachedService.UpdateModelCostAsync(_testModelCost); + + // Assert + Assert.False(result); + _mockCacheManager.Verify(x => x.ClearRegionAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteModelCostAsync_Success_InvalidatesCache() + { + // Arrange + _mockInnerService + .Setup(x => x.DeleteModelCostAsync(TestModelCostId, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _cachedService.DeleteModelCostAsync(TestModelCostId); + + // Assert + Assert.True(result); + _mockCacheManager.Verify(x => x.ClearRegionAsync( + CacheRegion.ModelCosts, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteModelCostAsync_NotFound_DoesNotInvalidateCache() + { + // Arrange + _mockInnerService + .Setup(x => x.DeleteModelCostAsync(TestModelCostId, It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _cachedService.DeleteModelCostAsync(TestModelCostId); + + // Assert + Assert.False(result); + _mockCacheManager.Verify(x => x.ClearRegionAsync( + It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + #region ClearCacheAsync Tests + + [Fact] + public async Task ClearCacheAsync_ClearsRegion() + { + // Act + await _cachedService.ClearCacheAsync(); + + // Assert + _mockCacheManager.Verify(x => x.ClearRegionAsync( + CacheRegion.ModelCosts, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ClearCacheAsync_CacheError_DoesNotThrow() + { + // Arrange + _mockCacheManager + .Setup(x => x.ClearRegionAsync(CacheRegion.ModelCosts, It.IsAny())) + .ThrowsAsync(new Exception("Cache failure")); + + // Act & Assert โ€” should not throw + await _cachedService.ClearCacheAsync(); + } + + #endregion + } +} diff --git a/Tests/ConduitLLM.Tests/Core/Services/DiscoveryCacheServiceTests.cs b/Tests/ConduitLLM.Tests/Core/Services/DiscoveryCacheServiceTests.cs index c1f9980fe..5eb015bd7 100644 --- a/Tests/ConduitLLM.Tests/Core/Services/DiscoveryCacheServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Core/Services/DiscoveryCacheServiceTests.cs @@ -66,7 +66,7 @@ public async Task GetDiscoveryResultsAsync_Should_Return_From_Cache_When_Availab var cacheKey = "discovery:models:all"; var expectedResult = new DiscoveryModelsResult { - Data = new List { new { id = "gpt-4", provider = "openai" } }, + Data = new List { JsonSerializer.SerializeToElement(new { id = "gpt-4", provider = "openai" }) }, Count = 1, CachedAt = DateTime.UtcNow }; @@ -108,7 +108,7 @@ public async Task SetDiscoveryResultsAsync_Should_Not_Cache_When_Caching_Disable // Arrange _options.EnableCaching = false; var service = CreateServiceWithOptions(_options); - var results = new DiscoveryModelsResult { Data = new List { new { id = "test" } }, Count = 1 }; + var results = new DiscoveryModelsResult { Data = new List { JsonSerializer.SerializeToElement(new { id = "test" }) }, Count = 1 }; // Act await service.SetDiscoveryResultsAsync("test-key", results); @@ -124,7 +124,7 @@ public async Task SetDiscoveryResultsAsync_Should_Cache_With_Correct_TTL() var cacheKey = "discovery:models:capability:chat"; var results = new DiscoveryModelsResult { - Data = new List { new { id = "gpt-4", provider = "openai" } }, + Data = new List { JsonSerializer.SerializeToElement(new { id = "gpt-4", provider = "openai" }) }, Count = 1 }; @@ -222,7 +222,7 @@ public async Task GetStatisticsAsync_Should_Return_Correct_Stats() await _service.GetDiscoveryResultsAsync(cacheKey); // Setup for a hit - var result = new DiscoveryModelsResult { Data = new List(), Count = 0 }; + var result = new DiscoveryModelsResult { Data = new List(), Count = 0 }; _mockCacheManager .Setup(x => x.GetAsync(cacheKey, ConduitLLM.Core.Models.CacheRegion.ModelDiscovery, It.IsAny())) .ReturnsAsync(result); @@ -259,7 +259,7 @@ public async Task SetDiscoveryResultsAsync_Should_Use_CacheManager() { // Arrange var cacheKey = "test-key"; - var results = new DiscoveryModelsResult { Data = new List(), Count = 0 }; + var results = new DiscoveryModelsResult { Data = new List(), Count = 0 }; // Act await _service.SetDiscoveryResultsAsync(cacheKey, results); diff --git a/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.GroupFilter.cs b/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.GroupFilter.cs index 17d29043e..a4bc7b9e3 100644 --- a/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.GroupFilter.cs +++ b/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.GroupFilter.cs @@ -127,8 +127,9 @@ public async Task GetOverallStorageStatsAsync_WithGroupId_ReturnsFilteredStats() } }; - mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdAsync(groupId, default)) - .ReturnsAsync(virtualKeys); + mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + groupId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((virtualKeys, virtualKeys.Count)); _mockMediaRepository.Setup(x => x.GetByVirtualKeyIdAsync(1)) .ReturnsAsync(mediaForKey1); _mockMediaRepository.Setup(x => x.GetByVirtualKeyIdAsync(3)) @@ -177,8 +178,9 @@ public async Task GetOverallStorageStatsAsync_WithEmptyGroup_ReturnsEmptyStats() const int groupId = 999; var virtualKeys = new List(); // Empty list - mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdAsync(groupId, default)) - .ReturnsAsync(virtualKeys); + mockVirtualKeyRepository.Setup(x => x.GetByVirtualKeyGroupIdPaginatedAsync( + groupId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((virtualKeys, 0)); // Act var result = await service.GetOverallStorageStatsAsync(groupId); diff --git a/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.TrackMedia.cs b/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.TrackMedia.cs index 17982c975..c5c2d56cb 100644 --- a/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.TrackMedia.cs +++ b/Tests/ConduitLLM.Tests/Core/Services/MediaLifecycleServiceTests.TrackMedia.cs @@ -30,27 +30,10 @@ public async Task TrackMediaAsync_WithValidParameters_ShouldCreateMediaRecord() ExpiresAt = DateTime.UtcNow.AddDays(30) }; - var expectedMediaRecord = new MediaRecord - { - Id = Guid.NewGuid(), - StorageKey = storageKey, - VirtualKeyId = virtualKeyId, - MediaType = mediaType, - ContentType = metadata.ContentType, - SizeBytes = metadata.SizeBytes, - ContentHash = metadata.ContentHash, - Provider = metadata.Provider, - Model = metadata.Model, - Prompt = metadata.Prompt, - StorageUrl = metadata.StorageUrl, - PublicUrl = metadata.PublicUrl, - ExpiresAt = metadata.ExpiresAt, - CreatedAt = DateTime.UtcNow, - AccessCount = 0 - }; + var expectedId = Guid.NewGuid(); - _mockMediaRepository.Setup(x => x.CreateAsync(It.IsAny())) - .ReturnsAsync(expectedMediaRecord); + _mockMediaRepository.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedId); // Act var result = await _service.TrackMediaAsync(virtualKeyId, storageKey, mediaType, metadata); @@ -85,7 +68,7 @@ public async Task TrackMediaAsync_WithValidParameters_ShouldCreateMediaRecord() r.PublicUrl == metadata.PublicUrl && r.ExpiresAt == metadata.ExpiresAt && r.AccessCount == 0 - )), Times.Once); + ), It.IsAny()), Times.Once); } [Fact] @@ -96,27 +79,10 @@ public async Task TrackMediaAsync_WithNullMetadata_ShouldCreateMediaRecordWithNu var storageKey = "image/2023/01/01/test-hash.jpg"; var mediaType = "image"; - var expectedMediaRecord = new MediaRecord - { - Id = Guid.NewGuid(), - StorageKey = storageKey, - VirtualKeyId = virtualKeyId, - MediaType = mediaType, - ContentType = null, - SizeBytes = null, - ContentHash = null, - Provider = null, - Model = null, - Prompt = null, - StorageUrl = null, - PublicUrl = null, - ExpiresAt = null, - CreatedAt = DateTime.UtcNow, - AccessCount = 0 - }; + var expectedId = Guid.NewGuid(); - _mockMediaRepository.Setup(x => x.CreateAsync(It.IsAny())) - .ReturnsAsync(expectedMediaRecord); + _mockMediaRepository.Setup(x => x.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedId); // Act var result = await _service.TrackMediaAsync(virtualKeyId, storageKey, mediaType, null); diff --git a/Tests/ConduitLLM.Tests/Core/Services/PerformanceMetricsServiceTests.StreamingTracker.cs b/Tests/ConduitLLM.Tests/Core/Services/PerformanceMetricsServiceTests.StreamingTracker.cs index cfdfaa859..290ce3a3d 100644 --- a/Tests/ConduitLLM.Tests/Core/Services/PerformanceMetricsServiceTests.StreamingTracker.cs +++ b/Tests/ConduitLLM.Tests/Core/Services/PerformanceMetricsServiceTests.StreamingTracker.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; @@ -14,7 +15,7 @@ public void CreateStreamingTracker_CreatesValidTracker() // Assert Assert.NotNull(tracker); - Assert.IsAssignableFrom(tracker); + tracker.Should().BeAssignableTo(); } [Fact] @@ -94,8 +95,9 @@ public void StreamingTracker_CalculatesInterTokenLatency() // Assert Assert.NotNull(metrics.AvgInterTokenLatencyMs); - Assert.True(metrics.AvgInterTokenLatencyMs >= 15); // Should be around 20ms - Assert.True(metrics.AvgInterTokenLatencyMs <= 30); + // Allow wider tolerance for timing-sensitive tests due to thread scheduling and system load + Assert.True(metrics.AvgInterTokenLatencyMs >= 10, $"Inter-token latency {metrics.AvgInterTokenLatencyMs}ms was less than minimum expected 10ms"); + Assert.True(metrics.AvgInterTokenLatencyMs <= 100, $"Inter-token latency {metrics.AvgInterTokenLatencyMs}ms exceeded maximum expected 100ms"); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Core/Services/PromptCacheInjectionServiceTests.cs b/Tests/ConduitLLM.Tests/Core/Services/PromptCacheInjectionServiceTests.cs new file mode 100644 index 000000000..84595df17 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Core/Services/PromptCacheInjectionServiceTests.cs @@ -0,0 +1,307 @@ +using System.Text.Json; +using ConduitLLM.Core.Models; +using ConduitLLM.Core.Services; +using FluentAssertions; +using Xunit; + +namespace ConduitLLM.Tests.Core.Services; + +public class PromptCacheInjectionServiceTests +{ + private static ChatCompletionRequest CreateRequest(params Message[] messages) + { + return new ChatCompletionRequest + { + Model = "test-model", + Messages = messages.ToList() + }; + } + + [Fact] + public void InjectCacheControl_ByRole_InjectsOnSystemMessage() + { + // Arrange + var request = CreateRequest( + new Message { Role = "system", Content = "You are a helpful assistant." }, + new Message { Role = "user", Content = "Hello" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system" } + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” system message should now be a content array with cache_control + var content = request.Messages[0].Content; + content.Should().BeAssignableTo>(); + + var contentList = (IList)content!; + contentList.Should().HaveCount(1); + + var block = contentList[0] as Dictionary; + block.Should().NotBeNull(); + block!["type"].Should().Be("text"); + block["text"].Should().Be("You are a helpful assistant."); + block.Should().ContainKey("cache_control"); + + // User message should be unchanged + request.Messages[1].Content.Should().Be("Hello"); + } + + [Fact] + public void InjectCacheControl_ByNegativeIndex_InjectsOnLastMessage() + { + // Arrange + var request = CreateRequest( + new Message { Role = "user", Content = "First message" }, + new Message { Role = "user", Content = "Second message" }, + new Message { Role = "user", Content = "Third message" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "user", Index = -1 } + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” only the last user message should be modified + request.Messages[0].Content.Should().Be("First message"); + request.Messages[1].Content.Should().Be("Second message"); + + var content = request.Messages[2].Content; + content.Should().BeAssignableTo>(); + } + + [Fact] + public void InjectCacheControl_ByIndex_InjectsOnFirstMessage() + { + // Arrange + var request = CreateRequest( + new Message { Role = "user", Content = "First" }, + new Message { Role = "user", Content = "Second" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "user", Index = 0 } + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” only the first user message should be modified + request.Messages[0].Content.Should().BeAssignableTo>(); + request.Messages[1].Content.Should().Be("Second"); + } + + [Fact] + public void InjectCacheControl_JsonElementContent_PreservesExistingBlocksAndAddsCacheControl() + { + // Arrange โ€” content is already a JSON array (as it would be from deserialization) + var contentJson = """ + [ + { "type": "text", "text": "Part 1" }, + { "type": "text", "text": "Part 2" } + ] + """; + var jsonContent = JsonSerializer.Deserialize(contentJson); + + var request = CreateRequest( + new Message { Role = "system", Content = jsonContent } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system" } + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” should have 2 blocks, last one with cache_control + var content = request.Messages[0].Content as IList; + content.Should().NotBeNull(); + content.Should().HaveCount(2); + + var lastBlock = content![1] as Dictionary; + lastBlock.Should().NotBeNull(); + lastBlock.Should().ContainKey("cache_control"); + + // First block should NOT have cache_control + var firstBlock = content[0] as Dictionary; + firstBlock.Should().NotBeNull(); + firstBlock.Should().NotContainKey("cache_control"); + } + + [Fact] + public void InjectCacheControl_MaxFourBlocks_StopsAtLimit() + { + // Arrange โ€” 5 system messages, should only inject on first 4 + var messages = Enumerable.Range(1, 5) + .Select(i => new Message { Role = "system", Content = $"Message {i}" }) + .ToArray(); + var request = CreateRequest(messages); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system" } // Matches all 5 + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” first 4 should be modified, 5th should be unchanged + for (int i = 0; i < 4; i++) + { + request.Messages[i].Content.Should().BeAssignableTo>( + $"Message {i} should be converted to content array"); + } + + request.Messages[4].Content.Should().Be("Message 5", + "5th message should be unchanged (max 4 cache blocks)"); + } + + [Fact] + public void InjectCacheControl_Disabled_DoesNothing() + { + // Arrange + var request = CreateRequest( + new Message { Role = "system", Content = "System prompt" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = false, + InjectionPoints = new List + { + new() { Role = "system" } + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” should be unchanged + request.Messages[0].Content.Should().Be("System prompt"); + } + + [Fact] + public void InjectCacheControl_EmptyInjectionPoints_DoesNothing() + { + // Arrange + var request = CreateRequest( + new Message { Role = "system", Content = "System prompt" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List() + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert + request.Messages[0].Content.Should().Be("System prompt"); + } + + [Fact] + public void InjectCacheControl_NoMatchingRole_DoesNothing() + { + // Arrange + var request = CreateRequest( + new Message { Role = "user", Content = "Hello" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system" } + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert + request.Messages[0].Content.Should().Be("Hello"); + } + + [Fact] + public void InjectCacheControl_NullRole_MatchesAnyRole() + { + // Arrange + var request = CreateRequest( + new Message { Role = "system", Content = "System" }, + new Message { Role = "user", Content = "User" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = null, Index = -1 } // Last message of any role + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” only last message should be modified + request.Messages[0].Content.Should().Be("System"); + request.Messages[1].Content.Should().BeAssignableTo>(); + } + + [Fact] + public void InjectCacheControl_OutOfRangeIndex_DoesNothing() + { + // Arrange + var request = CreateRequest( + new Message { Role = "system", Content = "Only one" } + ); + + var config = new PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system", Index = 5 } // Out of range + } + }; + + // Act + PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert + request.Messages[0].Content.Should().Be("Only one"); + } +} diff --git a/Tests/ConduitLLM.Tests/Core/Services/RedisRateLimitServiceTests.cs b/Tests/ConduitLLM.Tests/Core/Services/RedisRateLimitServiceTests.cs index 647c8229a..c2a292e7f 100644 --- a/Tests/ConduitLLM.Tests/Core/Services/RedisRateLimitServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Core/Services/RedisRateLimitServiceTests.cs @@ -7,6 +7,7 @@ using Moq; using StackExchange.Redis; using Xunit; +using ConduitLLM.Core.Constants; using ConduitLLM.Core.Services; using Xunit.Abstractions; @@ -268,7 +269,7 @@ public async Task SlidingWindow_ShouldExpireOldRequests() // Wait for window to expire (simplified test - in production this is 60 seconds) // For testing, we can manually clear the key to simulate expiration - await _db.KeyDeleteAsync($"rate:vk:{virtualKeyHash}:rpm"); + await _db.KeyDeleteAsync(RedisKeys.RateLimit.VirtualKeyRpm(virtualKeyHash)); // Request after window expiration should succeed var result4 = await _rateLimitService.CheckRateLimitAsync(virtualKeyHash, rpmLimit, null); @@ -307,7 +308,7 @@ public async Task RateLimitUsage_ShouldReturnAccurateStatistics() { // Try to get the raw data from Redis to understand what's happening var db = _redis.GetDatabase(); - var rpmKey = $"rate:vk:{virtualKeyHash}:rpm"; + var rpmKey = RedisKeys.RateLimit.VirtualKeyRpm(virtualKeyHash); var rpmCount = await db.SortedSetLengthAsync(rpmKey); Assert.Equal(3, rpmCount); // This will fail with more info } diff --git a/Tests/ConduitLLM.Tests/Core/Services/RedisWebhookMetricsServiceTests.cs b/Tests/ConduitLLM.Tests/Core/Services/RedisWebhookMetricsServiceTests.cs index 7b897ed21..81c08cf91 100644 --- a/Tests/ConduitLLM.Tests/Core/Services/RedisWebhookMetricsServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Core/Services/RedisWebhookMetricsServiceTests.cs @@ -6,6 +6,7 @@ using Moq; using StackExchange.Redis; using Xunit; +using ConduitLLM.Core.Constants; using ConduitLLM.Core.Services; using ConduitLLM.Configuration.DTOs.SignalR; @@ -194,29 +195,41 @@ public async Task GetUrlStatisticsAsync_ReturnsCorrectStatistics() Assert.True(stats.IsHealthy); } - [Fact(Skip = "Known issue with mocking IServer.Keys() enumeration - needs investigation")] + [Fact] public async Task GetStatisticsAsync_AggregatesMultipleUrls() { // Arrange - var keys = new RedisKey[] - { - "webhook:metrics:urls:hash1", - "webhook:metrics:urls:hash2" + var keys = new RedisKey[] + { + RedisKeys.WebhookMetrics.UrlMetrics("hash1"), + RedisKeys.WebhookMetrics.UrlMetrics("hash2") }; - + // Setup Keys method to return test keys + // IServer has two Keys overloads - set up both + IEnumerable keysList = keys.ToList(); + + // Setup 4-parameter overload _serverMock.Setup(s => s.Keys( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) - .Returns(keys); - - // Use a time that's definitely within the last hour - var recentTime = DateTime.UtcNow.AddMinutes(-30).ToString("O"); - + .Returns(keysList); + + // Setup 6-parameter overload + _serverMock.Setup(s => s.Keys( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(keysList); + + // Use current time - format as simple sortable string that DateTime.TryParse handles correctly + var recentTime = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); + var hashEntries1 = new HashEntry[] { new HashEntry("url", "https://example1.com/webhook"), @@ -225,7 +238,7 @@ public async Task GetStatisticsAsync_AggregatesMultipleUrls() new HashEntry("failures", 5), new HashEntry("last_attempt", recentTime) }; - + var hashEntries2 = new HashEntry[] { new HashEntry("url", "https://example2.com/webhook"), @@ -234,49 +247,41 @@ public async Task GetStatisticsAsync_AggregatesMultipleUrls() new HashEntry("failures", 5), new HashEntry("last_attempt", recentTime) }; - - // Setup HashGetAllAsync to return data based on the key + + // Setup HashGetAllAsync with specific key matchers + // In Moq, specific matchers take precedence over generic ones when set up later _databaseMock.Setup(d => d.HashGetAllAsync( - It.IsAny(), + It.IsAny(), It.IsAny())) - .ReturnsAsync((RedisKey key, CommandFlags flags) => - { - if (key.ToString().Contains("hash1")) - return hashEntries1; - else if (key.ToString().Contains("hash2")) - return hashEntries2; - else - return new HashEntry[0]; - }); - + .ReturnsAsync(Array.Empty()); + + _databaseMock.Setup(d => d.HashGetAllAsync( + It.Is(k => k.ToString().Contains("hash1")), + It.IsAny())) + .ReturnsAsync(hashEntries1); + + _databaseMock.Setup(d => d.HashGetAllAsync( + It.Is(k => k.ToString().Contains("hash2")), + It.IsAny())) + .ReturnsAsync(hashEntries2); + // Act var stats = await _metricsService.GetStatisticsAsync("last_hour"); - - // Verify Keys was called and capture the actual call + + // Verify Keys was called _serverMock.Verify(s => s.Keys( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Once); - + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.AtLeastOnce); + // Verify HashGetAllAsync was called for each key _databaseMock.Verify(d => d.HashGetAllAsync( - It.IsAny(), + It.IsAny(), It.IsAny()), Times.Exactly(2)); - - // Check if error was logged (which would indicate the method caught an exception) - _loggerMock.Verify( - x => x.Log( - It.Is(l => l == LogLevel.Error), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()), - Times.Never, - "No errors should be logged"); - + // Assert Assert.Equal("last_hour", stats.Period); Assert.Equal(2, stats.UrlStatistics.Count); @@ -357,4 +362,4 @@ public void Dispose() // Cleanup if needed } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Functions/Utilities/JsonElementConverterTests.cs b/Tests/ConduitLLM.Tests/Functions/Utilities/JsonElementConverterTests.cs index a3d43c14f..86ed82d87 100644 --- a/Tests/ConduitLLM.Tests/Functions/Utilities/JsonElementConverterTests.cs +++ b/Tests/ConduitLLM.Tests/Functions/Utilities/JsonElementConverterTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using FluentAssertions; using ConduitLLM.Functions.Utilities; namespace ConduitLLM.Tests.Functions.Utilities @@ -616,8 +617,7 @@ public void ConvertJsonElement_WithJsonElementArray_ReturnsList() var element = CreateJsonElement("[1, 2, 3]"); var result = JsonElementConverter.ConvertJsonElement(element); - Assert.IsType>(result); - var list = (List)result; + var list = result.Should().BeOfType>().Subject; Assert.Equal(3, list.Count); Assert.Equal(1, list[0]); Assert.Equal(2, list[1]); @@ -630,8 +630,7 @@ public void ConvertJsonElement_WithJsonElementObject_ReturnsDictionary() var element = CreateJsonElement("{\"name\": \"test\", \"value\": 42}"); var result = JsonElementConverter.ConvertJsonElement(element); - Assert.IsType>(result); - var dict = (Dictionary)result; + var dict = result.Should().BeOfType>().Subject; Assert.Equal(2, dict.Count); Assert.Equal("test", dict["name"]); Assert.Equal(42, dict["value"]); @@ -643,10 +642,8 @@ public void ConvertJsonElement_WithNestedObject_ReturnsNestedStructure() var element = CreateJsonElement("{\"outer\": {\"inner\": \"value\"}}"); var result = JsonElementConverter.ConvertJsonElement(element); - Assert.IsType>(result); - var outer = (Dictionary)result; - Assert.IsType>(outer["outer"]); - var inner = (Dictionary)outer["outer"]; + var outer = result.Should().BeOfType>().Subject; + var inner = outer["outer"].Should().BeOfType>().Subject; Assert.Equal("value", inner["inner"]); } @@ -656,8 +653,7 @@ public void ConvertJsonElement_WithMixedArray_ReturnsConvertedList() var element = CreateJsonElement("[\"string\", 42, true, null]"); var result = JsonElementConverter.ConvertJsonElement(element); - Assert.IsType>(result); - var list = (List)result; + var list = result.Should().BeOfType>().Subject; Assert.Equal(4, list.Count); Assert.Equal("string", list[0]); Assert.Equal(42, list[1]); @@ -726,8 +722,7 @@ public void ConvertJsonElement_ComplexNestedStructure_ConvertsCorrectly() var element = CreateJsonElement(json); var result = JsonElementConverter.ConvertJsonElement(element); - Assert.IsType>(result); - var root = (Dictionary)result; + var root = result.Should().BeOfType>().Subject; var search = (Dictionary)root["search"]; Assert.Equal("test query", search["query"]); diff --git a/Tests/ConduitLLM.Tests/Gateway/Authorization/RequireBalanceAttributeTests.cs b/Tests/ConduitLLM.Tests/Gateway/Authorization/RequireBalanceAttributeTests.cs index 8c99b6b3a..b66041ae9 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Authorization/RequireBalanceAttributeTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Authorization/RequireBalanceAttributeTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -60,9 +61,9 @@ public async Task OnAuthorizationAsync_WithInsufficientBalance_Returns402Payment await _attribute.OnAuthorizationAsync(context); // Assert - var objectResult = Assert.IsType(context.Result); + var objectResult = context.Result.Should().BeOfType().Subject; Assert.Equal(StatusCodes.Status402PaymentRequired, objectResult.StatusCode); - + // Check response body var responseBody = objectResult.Value; Assert.NotNull(responseBody); @@ -89,7 +90,7 @@ public async Task OnAuthorizationAsync_WithoutVirtualKeyClaim_Returns401Unauthor await _attribute.OnAuthorizationAsync(context); // Assert - var objectResult = Assert.IsType(context.Result); + var objectResult = context.Result.Should().BeOfType().Subject; Assert.Equal(StatusCodes.Status401Unauthorized, objectResult.StatusCode); // Verify service was never called @@ -110,9 +111,9 @@ public async Task OnAuthorizationAsync_WithInvalidVirtualKey_Returns402PaymentRe await _attribute.OnAuthorizationAsync(context); // Assert - var objectResult = Assert.IsType(context.Result); + var objectResult = context.Result.Should().BeOfType().Subject; Assert.Equal(StatusCodes.Status402PaymentRequired, objectResult.StatusCode); - + _virtualKeyServiceMock.Verify(s => s.ValidateVirtualKeyAsync("invalid-key", null), Times.Once); } @@ -129,9 +130,9 @@ public async Task OnAuthorizationAsync_WithServiceException_Returns500InternalSe await _attribute.OnAuthorizationAsync(context); // Assert - var objectResult = Assert.IsType(context.Result); + var objectResult = context.Result.Should().BeOfType().Subject; Assert.Equal(StatusCodes.Status500InternalServerError, objectResult.StatusCode); - + var responseBody = objectResult.Value; Assert.NotNull(responseBody); diff --git a/Tests/ConduitLLM.Tests/Gateway/Consumers/ModelMappingCacheInvalidationConsumerTests.cs b/Tests/ConduitLLM.Tests/Gateway/Consumers/ModelMappingCacheInvalidationHandlerTests.cs similarity index 96% rename from Tests/ConduitLLM.Tests/Gateway/Consumers/ModelMappingCacheInvalidationConsumerTests.cs rename to Tests/ConduitLLM.Tests/Gateway/Consumers/ModelMappingCacheInvalidationHandlerTests.cs index bb8b9d3c2..fbf131f66 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Consumers/ModelMappingCacheInvalidationConsumerTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Consumers/ModelMappingCacheInvalidationHandlerTests.cs @@ -14,20 +14,20 @@ namespace ConduitLLM.Tests.Http.Consumers { [Trait("Category", "Unit")] - public class ModelMappingCacheInvalidationConsumerTests + public class ModelMappingCacheInvalidationHandlerTests { private readonly Mock _mockCacheManager; private readonly Mock _mockDiscoveryCacheService; - private readonly Mock> _mockLogger; - private readonly ModelMappingCacheInvalidationConsumer _consumer; + private readonly Mock> _mockLogger; + private readonly ModelMappingCacheInvalidationHandler _consumer; - public ModelMappingCacheInvalidationConsumerTests() + public ModelMappingCacheInvalidationHandlerTests() { _mockCacheManager = new Mock(); _mockDiscoveryCacheService = new Mock(); - _mockLogger = new Mock>(); + _mockLogger = new Mock>(); - _consumer = new ModelMappingCacheInvalidationConsumer( + _consumer = new ModelMappingCacheInvalidationHandler( _mockCacheManager.Object, _mockDiscoveryCacheService.Object, _mockLogger.Object); @@ -310,7 +310,7 @@ public void Constructor_Should_Throw_ArgumentNullException_For_Null_CacheManager { // Act & Assert Assert.Throws(() => - new ModelMappingCacheInvalidationConsumer( + new ModelMappingCacheInvalidationHandler( null!, _mockDiscoveryCacheService.Object, _mockLogger.Object)); @@ -321,7 +321,7 @@ public void Constructor_Should_Throw_ArgumentNullException_For_Null_DiscoveryCac { // Act & Assert Assert.Throws(() => - new ModelMappingCacheInvalidationConsumer( + new ModelMappingCacheInvalidationHandler( _mockCacheManager.Object, null!, _mockLogger.Object)); @@ -332,7 +332,7 @@ public void Constructor_Should_Throw_ArgumentNullException_For_Null_Logger() { // Act & Assert Assert.Throws(() => - new ModelMappingCacheInvalidationConsumer( + new ModelMappingCacheInvalidationHandler( _mockCacheManager.Object, _mockDiscoveryCacheService.Object, null!)); diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/BatchOperationsControllerTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/BatchOperationsControllerTests.cs index de5d383c7..b1ac1d1c4 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/BatchOperationsControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/BatchOperationsControllerTests.cs @@ -5,12 +5,12 @@ using ConduitLLM.Gateway.Controllers; using ConduitLLM.Configuration.DTOs.BatchOperations; using ConduitLLM.Core.Services.BatchOperations; +using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using Xunit.Abstractions; -using ConduitLLM.Configuration.DTOs; namespace ConduitLLM.Tests.Http.Controllers { @@ -88,8 +88,8 @@ public void GetOperationStatus_WithExistingOperation_ShouldReturnOk() var result = _controller.GetOperationStatus(operationId); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; Assert.Equal(operationId, response.OperationId); Assert.Equal("Running", response.Status); Assert.Equal(50, response.ProcessedCount); @@ -107,9 +107,9 @@ public void GetOperationStatus_WithNonExistentOperation_ShouldReturnNotFound() var result = _controller.GetOperationStatus(operationId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - Assert.Equal("Operation not found", errorResponse.error.ToString()); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + Assert.Equal("Operation not found", errorResponse.Error.Message); } #endregion @@ -137,7 +137,7 @@ public async Task CancelOperation_WithCancellableOperation_ShouldReturnNoContent var result = await _controller.CancelOperation(operationId); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -158,9 +158,9 @@ public async Task CancelOperation_WithNonCancellableOperation_ShouldReturnConfli var result = await _controller.CancelOperation(operationId); // Assert - var conflictResult = Assert.IsType(result); - var errorResponse = Assert.IsType(conflictResult.Value); - Assert.Equal("Operation cannot be cancelled", errorResponse.error.ToString()); + var conflictResult = result.Should().BeOfType().Subject; + var errorResponse = conflictResult.Value.Should().BeOfType().Subject; + Assert.Equal("Operation cannot be cancelled", errorResponse.Error.Message); } [Fact] @@ -184,9 +184,9 @@ public async Task CancelOperation_WithFailedCancellation_ShouldReturnConflict() var result = await _controller.CancelOperation(operationId); // Assert - var conflictResult = Assert.IsType(result); - var errorResponse = Assert.IsType(conflictResult.Value); - Assert.Equal("Failed to cancel operation", errorResponse.error.ToString()); + var conflictResult = result.Should().BeOfType().Subject; + var errorResponse = conflictResult.Value.Should().BeOfType().Subject; + Assert.Equal("Failed to cancel operation", errorResponse.Error.Message); } [Fact] @@ -201,9 +201,9 @@ public async Task CancelOperation_WithNonExistentOperation_ShouldReturnNotFound( var result = await _controller.CancelOperation(operationId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - Assert.Equal("Operation not found", errorResponse.error.ToString()); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + Assert.Equal("Operation not found", errorResponse.Error.Message); } #endregion @@ -259,8 +259,8 @@ public async Task StartBatchSpendUpdate_WithValidRequest_ShouldReturnAccepted() var result = await _controller.StartBatchSpendUpdate(request); // Assert - var acceptedResult = Assert.IsType(result); - var response = Assert.IsType(acceptedResult.Value); + var acceptedResult = result.Should().BeOfType().Subject; + var response = acceptedResult.Value.Should().BeOfType().Subject; Assert.Equal("batch-op-123", response.OperationId); Assert.Equal("spend_update", response.OperationType); Assert.Equal(1, response.TotalItems); @@ -290,9 +290,9 @@ public async Task StartBatchSpendUpdate_WithEmptyUpdates_ShouldReturnBadRequest( var result = await _controller.StartBatchSpendUpdate(request); // Assert - var badRequestResult = Assert.IsType(result); - var errorResponse = Assert.IsType(badRequestResult.Value); - Assert.Equal("No updates provided", errorResponse.error.ToString()); + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + Assert.Equal("No updates provided", errorResponse.Error.Message); } #endregion @@ -400,4 +400,4 @@ public void Controller_ShouldRequireAuthorization() #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/ControllerTestBase.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/ControllerTestBase.cs index 0de780995..d02130b7f 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/ControllerTestBase.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/ControllerTestBase.cs @@ -1,3 +1,5 @@ +using FluentAssertions; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -88,10 +90,10 @@ protected ControllerContext CreateControllerContextWithBody(T body) /// protected void AssertOkObjectResult(IActionResult result, Action assertions = null) { - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; Assert.NotNull(okResult.Value); - - var value = Assert.IsType(okResult.Value); + + var value = okResult.Value.Should().BeOfType().Subject; assertions?.Invoke(value); } @@ -100,8 +102,8 @@ protected void AssertOkObjectResult(IActionResult result, Action assertion /// protected void AssertBadRequest(IActionResult result, string expectedMessage = null) { - var badRequestResult = Assert.IsType(result); - + var badRequestResult = result.Should().BeOfType().Subject; + if (!string.IsNullOrEmpty(expectedMessage)) { Assert.Equal(expectedMessage, badRequestResult.Value?.ToString()); @@ -113,7 +115,7 @@ protected void AssertBadRequest(IActionResult result, string expectedMessage = n /// protected void AssertNotFound(IActionResult result) { - Assert.IsType(result); + result.Should().BeOfType(); } /// @@ -121,7 +123,7 @@ protected void AssertNotFound(IActionResult result) /// protected void AssertUnauthorized(IActionResult result) { - Assert.IsType(result); + result.Should().BeOfType(); } /// @@ -129,9 +131,9 @@ protected void AssertUnauthorized(IActionResult result) /// protected void AssertInternalServerError(IActionResult result, string expectedMessage = null) { - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - + if (!string.IsNullOrEmpty(expectedMessage)) { Assert.Equal(expectedMessage, objectResult.Value?.ToString()); diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetCapabilitiesTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetCapabilitiesTests.cs index 4b8f8d495..cf503e1fe 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetCapabilitiesTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetCapabilitiesTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using Xunit.Abstractions; @@ -19,10 +20,10 @@ public async Task GetCapabilities_ReturnsStaticListOfAllCapabilities() var result = await Controller.GetCapabilities(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; var capabilities = (string[])response.capabilities; - + Assert.Contains("chat", capabilities); Assert.Contains("chat_stream", capabilities); Assert.Contains("vision", capabilities); @@ -41,7 +42,7 @@ public async Task GetCapabilities_ReturnsCorrectNumberOfCapabilities() var result = await Controller.GetCapabilities(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; var capabilities = (string[])response.capabilities; Assert.Equal(9, capabilities.Length); diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetModelParametersTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetModelParametersTests.cs index 92803f71d..b2837089b 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetModelParametersTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/DiscoveryControllerGetModelParametersTests.cs @@ -1,9 +1,10 @@ using System.Security.Claims; +using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; -using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Models; using ConduitLLM.Configuration.Entities; using ConduitLLM.Tests.Http.Builders; using Xunit.Abstractions; @@ -31,9 +32,10 @@ public async Task GetModelParameters_WithoutVirtualKeyClaim_ShouldReturnUnauthor var result = await Controller.GetModelParameters("gpt-4"); // Assert - var unauthorizedResult = Assert.IsType(result); - var errorDto = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Virtual key not found", errorDto.error.ToString()); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Virtual key not found", errorResponse.Error.Message); } [Fact] @@ -66,7 +68,7 @@ public async Task GetModelParameters_WithValidModelAlias_ReturnsParameters() var result = await Controller.GetModelParameters("gpt-4"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(1, response.model_id); Assert.Equal("gpt-4", response.model_alias); @@ -96,7 +98,7 @@ public async Task GetModelParameters_WithNumericModelId_ReturnsParameters() var result = await Controller.GetModelParameters("123"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(123, response.model_id); Assert.Equal("gpt-4", response.model_alias); @@ -113,9 +115,10 @@ public async Task GetModelParameters_WithNonExistentModel_ReturnsNotFound() var result = await Controller.GetModelParameters("non-existent"); // Assert - var notFoundResult = Assert.IsType(result); - var errorDto = Assert.IsType(notFoundResult.Value); - Assert.Equal("Model 'non-existent' not found or has no parameter information", errorDto.error.ToString()); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Model 'non-existent' not found or has no parameter information", errorResponse.Error.Message); } [Fact] @@ -139,7 +142,7 @@ public async Task GetModelParameters_WithInvalidParametersJson_ReturnsEmptyObjec var result = await Controller.GetModelParameters("gpt-4"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.NotNull(response.parameters); // Should return empty object, not null } @@ -157,10 +160,10 @@ public async Task GetModelParameters_WhenExceptionOccurs_Returns500Error() var result = await Controller.GetModelParameters("gpt-4"); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - var errorDto = Assert.IsType(objectResult.Value); - Assert.Equal("Failed to retrieve model parameters", errorDto.error.ToString()); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("An unexpected error occurred", errorResponse.Error.Message); } } } \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsAuthenticationTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsAuthenticationTests.cs index 0246b2870..da109b6fe 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsAuthenticationTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsAuthenticationTests.cs @@ -1,7 +1,8 @@ using System.Security.Claims; +using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Models; using ConduitLLM.Configuration.Entities; using Xunit.Abstractions; using Moq; @@ -29,9 +30,10 @@ public async Task GetModels_WithoutVirtualKeyClaim_ShouldReturnUnauthorized() var result = await Controller.GetModels(); // Assert - var unauthorizedResult = Assert.IsType(result); - var errorDto = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Virtual key not found", errorDto.error.ToString()); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Virtual key not found", errorResponse.Error.Message); } [Fact] @@ -51,9 +53,10 @@ public async Task GetModels_WithInvalidVirtualKey_ShouldReturnUnauthorized() var result = await Controller.GetModels(); // Assert - var unauthorizedResult = Assert.IsType(result); - var errorDto = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Invalid virtual key", errorDto.error.ToString()); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Invalid virtual key", errorResponse.Error.Message); } [Fact] @@ -73,9 +76,10 @@ public async Task GetModels_WithDisabledVirtualKey_ShouldReturnUnauthorized() var result = await Controller.GetModels(); // Assert - var unauthorizedResult = Assert.IsType(result); - var errorDto = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Invalid virtual key", errorDto.error.ToString()); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Invalid virtual key", errorResponse.Error.Message); } } } \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsCapabilityFilteringTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsCapabilityFilteringTests.cs index 48093066a..83afda9db 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsCapabilityFilteringTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsCapabilityFilteringTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using ConduitLLM.Configuration.Entities; using ConduitLLM.Tests.Http.Builders; @@ -38,10 +40,10 @@ public async Task GetModels_FilterByVisionCapability_ReturnsOnlyVisionModels() var result = await Controller.GetModels(capability: "vision"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(1, response.count); - Assert.Equal("gpt-4-vision", ((IEnumerable)response.data).First().id); + Assert.Equal("gpt-4-vision", ((List)response.data).First().GetProperty("id").GetString()); } [Fact] @@ -68,10 +70,10 @@ public async Task GetModels_FilterByStreamingCapability_ReturnsOnlyStreamingMode var result = await Controller.GetModels(capability: "streaming"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(1, response.count); - Assert.Equal("gpt-4", ((IEnumerable)response.data).First().id); + Assert.Equal("gpt-4", ((List)response.data).First().GetProperty("id").GetString()); } [Fact] @@ -98,7 +100,7 @@ public async Task GetModels_FilterByChatStreamCapability_ReturnsOnlyStreamingMod var result = await Controller.GetModels(capability: "chat_stream"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(1, response.count); } @@ -121,7 +123,7 @@ public async Task GetModels_FilterByInvalidCapability_ReturnsEmptyList() var result = await Controller.GetModels(capability: "invalid_capability"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(0, response.count); } @@ -146,7 +148,7 @@ public async Task GetModels_CapabilityFilterIsCaseInsensitive_WorksWithVariation var result = await Controller.GetModels(capability: "audio-transcription"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; // Should work as controller converts dashes to underscores Assert.NotNull(response); diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsDataRetrievalTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsDataRetrievalTests.cs index 49e388f96..994cad2be 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsDataRetrievalTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsDataRetrievalTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using ConduitLLM.Configuration.Entities; using ConduitLLM.Tests.Http.Builders; @@ -38,10 +40,10 @@ public async Task GetModels_WithValidKey_ReturnsAllEnabledModels() var result = await Controller.GetModels(capability: null); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(2, response.count); - Assert.Equal(2, ((IEnumerable)response.data).Count()); + Assert.Equal(2, ((List)response.data).Count); } [Fact] @@ -67,7 +69,7 @@ public async Task GetModels_SkipsModelsWithNullModel_ReturnsOnlyValid() var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(1, response.count); } @@ -96,7 +98,7 @@ public async Task GetModels_RespectsProviderIsEnabledFlag_ReturnsOnlyEnabledProv var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(1, response.count); } @@ -125,7 +127,7 @@ public async Task GetModels_RespectsModelProviderMappingIsEnabledFlag_ReturnsOnl var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(1, response.count); } diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsErrorHandlingTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsErrorHandlingTests.cs index 85b833603..a09489d1d 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsErrorHandlingTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsErrorHandlingTests.cs @@ -1,7 +1,7 @@ +using FluentAssertions; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using Moq; -using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Models; using Xunit.Abstractions; namespace ConduitLLM.Tests.Http.Controllers.Discovery.GetModels @@ -27,35 +27,12 @@ public async Task GetModels_WhenDatabaseExceptionOccurs_Returns500Error() // Act var result = await Controller.GetModels(); - // Assert - var objectResult = Assert.IsType(result); + // Assert - GatewayControllerBase returns OpenAIErrorResponse via ExceptionToResponseMapper + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - var errorDto = Assert.IsType(objectResult.Value); - Assert.Equal("Failed to retrieve model discovery information", errorDto.error.ToString()); - } - - [Fact] - public async Task GetModels_WhenExceptionOccurs_LogsError() - { - // Arrange - SetupValidVirtualKey("valid-key"); - var exception = new Exception("Test exception"); - - MockDbContextFactory.Setup(x => x.CreateDbContextAsync(It.IsAny())) - .ThrowsAsync(exception); - - // Act - await Controller.GetModels(); - - // Assert - MockLogger.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains("Error retrieving model discovery information")), - It.IsAny(), - It.IsAny>()), - Times.Once); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("An unexpected error occurred", errorResponse.Error.Message); + Assert.Equal("server_error", errorResponse.Error.Type); } } } \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsIntegrationTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsIntegrationTests.cs index f33a24026..650a4027c 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsIntegrationTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsIntegrationTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using ConduitLLM.Configuration.Entities; using ConduitLLM.Tests.Http.Builders; @@ -43,7 +45,7 @@ public async Task GetModels_WithMultipleModelsFromSameProvider_ReturnsAll() var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(3, response.count); } @@ -59,10 +61,10 @@ public async Task GetModels_WithEmptyDatabase_ReturnsEmptyList() var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(0, response.count); - Assert.Empty((IEnumerable)response.data); + Assert.Empty((List)response.data); } [Fact] @@ -85,10 +87,10 @@ public async Task GetModels_WithLargeResultSet_HandlesCorrectly() var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; Assert.Equal(150, response.count); - Assert.Equal(150, ((IEnumerable)response.data).Count()); + Assert.Equal(150, ((List)response.data).Count); } } } \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsResponseStructureTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsResponseStructureTests.cs index 53d5ef9b7..1532f7925 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsResponseStructureTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/Discovery/GetModels/GetModelsResponseStructureTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using ConduitLLM.Configuration.Entities; using ConduitLLM.Tests.Http.Builders; @@ -34,18 +36,19 @@ public async Task GetModels_ReturnsFlatStructureWithBooleanCapabilityFlags() var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; - dynamic model = ((IEnumerable)response.data).First(); - + var model = ((List)response.data).First(); + var capabilities = model.GetProperty("capabilities"); + // Capabilities are now nested under a 'capabilities' object - Assert.True(model.capabilities.chat); - Assert.True(model.capabilities.chat_stream); - Assert.True(model.capabilities.vision); - Assert.True(model.capabilities.function_calling); - Assert.True(model.capabilities.video_generation); - Assert.True(model.capabilities.image_generation); - Assert.True(model.capabilities.embeddings); + Assert.True(capabilities.GetProperty("chat").GetBoolean()); + Assert.True(capabilities.GetProperty("chat_stream").GetBoolean()); + Assert.True(capabilities.GetProperty("vision").GetBoolean()); + Assert.True(capabilities.GetProperty("function_calling").GetBoolean()); + Assert.True(capabilities.GetProperty("video_generation").GetBoolean()); + Assert.True(capabilities.GetProperty("image_generation").GetBoolean()); + Assert.True(capabilities.GetProperty("embeddings").GetBoolean()); } [Fact] @@ -71,16 +74,16 @@ public async Task GetModels_IncludesMetadataFields_ReturnsCompleteModelInfo() var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; - dynamic model = ((IEnumerable)response.data).First(); - - Assert.Equal("gpt-4", model.id); - Assert.Equal("gpt-4", model.display_name); - Assert.Equal("Advanced language model", model.description); - Assert.Equal("https://example.com/gpt-4", model.model_card_url); - Assert.Equal(8192, model.max_tokens); - Assert.Equal("cl100kbase", model.tokenizer_type); + var model = ((List)response.data).First(); + + Assert.Equal("gpt-4", model.GetProperty("id").GetString()); + Assert.Equal("gpt-4", model.GetProperty("display_name").GetString()); + Assert.Equal("Advanced language model", model.GetProperty("description").GetString()); + Assert.Equal("https://example.com/gpt-4", model.GetProperty("model_card_url").GetString()); + Assert.Equal(8192, model.GetProperty("max_tokens").GetInt32()); + Assert.Equal("cl100kbase", model.GetProperty("tokenizer_type").GetString()); } [Fact] @@ -104,12 +107,12 @@ public async Task GetModels_HandlesNullDescriptionAndModelCardUrl_ReturnsEmptySt var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; - dynamic model = ((IEnumerable)response.data).First(); - - Assert.Equal(string.Empty, model.description); - Assert.Equal(string.Empty, model.model_card_url); + var model = ((List)response.data).First(); + + Assert.Equal(string.Empty, model.GetProperty("description").GetString()); + Assert.Equal(string.Empty, model.GetProperty("model_card_url").GetString()); } [Fact] @@ -135,14 +138,14 @@ public async Task GetModels_UsesAssociationTokenOverrides_WhenPresent() var result = await Controller.GetModels(); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value!; - dynamic model = ((IEnumerable)response.data).First(); - + var model = ((List)response.data).First(); + // Verify that association overrides are used, not model defaults - Assert.Equal(642111, (int)model.max_input_tokens); // Association override - Assert.Equal(4096, (int)model.max_output_tokens); // Falls back to Model default since association is null - Assert.Equal(642111 + 4096, (int)model.max_tokens); // Combined total + Assert.Equal(642111, model.GetProperty("max_input_tokens").GetInt32()); // Association override + Assert.Equal(4096, model.GetProperty("max_output_tokens").GetInt32()); // Falls back to Model default since association is null + Assert.Equal(642111 + 4096, model.GetProperty("max_tokens").GetInt32()); // Combined total } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/DiscoveryControllerTests.GetModelParameters.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/DiscoveryControllerTests.GetModelParameters.cs index 49e9bd48a..81d144cc2 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/DiscoveryControllerTests.GetModelParameters.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/DiscoveryControllerTests.GetModelParameters.cs @@ -1,10 +1,11 @@ using System.Security.Claims; using System.Text.Json; using ConduitLLM.Configuration; -using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Models; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -127,12 +128,12 @@ public async Task GetModelParameters_WithValidModelAlias_ReturnsParameters() var result = await _controller.GetModelParameters("test-model"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; var response = okResult.Value; - + var json = JsonSerializer.Serialize(response); var jsonDoc = JsonDocument.Parse(json); - + Assert.Equal(1, jsonDoc.RootElement.GetProperty("model_id").GetInt32()); Assert.Equal("test-model", jsonDoc.RootElement.GetProperty("model_alias").GetString()); Assert.Equal("Test Series", jsonDoc.RootElement.GetProperty("series_name").GetString()); @@ -192,12 +193,12 @@ public async Task GetModelParameters_WithModelId_ReturnsParameters() var result = await _controller.GetModelParameters("42"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; var response = okResult.Value; - + var json = JsonSerializer.Serialize(response); var jsonDoc = JsonDocument.Parse(json); - + Assert.Equal(42, jsonDoc.RootElement.GetProperty("model_id").GetInt32()); Assert.Equal("test-model-42", jsonDoc.RootElement.GetProperty("model_alias").GetString()); } @@ -214,9 +215,10 @@ public async Task GetModelParameters_WithNonExistentModel_ReturnsNotFound() var result = await _controller.GetModelParameters("non-existent-model"); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - Assert.Contains("not found", errorResponse.error.ToString()?.ToLower() ?? ""); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Contains("not found", errorResponse.Error.Message.ToLower()); } [Fact] @@ -230,9 +232,10 @@ public async Task GetModelParameters_WithInvalidVirtualKey_ReturnsUnauthorized() var result = await _controller.GetModelParameters("test-model"); // Assert - var unauthorizedResult = Assert.IsType(result); - var errorResponse = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Invalid virtual key", errorResponse.error.ToString()); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Invalid virtual key", errorResponse.Error.Message); } [Fact] @@ -245,9 +248,10 @@ public async Task GetModelParameters_WithNoVirtualKey_ReturnsUnauthorized() var result = await _controller.GetModelParameters("test-model"); // Assert - var unauthorizedResult = Assert.IsType(result); - var errorResponse = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Virtual key not found", errorResponse.error.ToString()); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Virtual key not found", errorResponse.Error.Message); } [Fact] @@ -301,12 +305,12 @@ public async Task GetModelParameters_WithEmptyParameters_ReturnsEmptyObject() var result = await _controller.GetModelParameters("test-model"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; var response = okResult.Value; - + var json = JsonSerializer.Serialize(response); var jsonDoc = JsonDocument.Parse(json); - + Assert.True(jsonDoc.RootElement.TryGetProperty("parameters", out var parameters)); Assert.Equal(JsonValueKind.Object, parameters.ValueKind); var count = 0; @@ -366,12 +370,12 @@ public async Task GetModelParameters_WithInvalidJson_ReturnsEmptyObject() var result = await _controller.GetModelParameters("test-model"); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; var response = okResult.Value; - + var json = JsonSerializer.Serialize(response); var jsonDoc = JsonDocument.Parse(json); - + Assert.True(jsonDoc.RootElement.TryGetProperty("parameters", out var parameters)); Assert.Equal(JsonValueKind.Object, parameters.ValueKind); var count = 0; @@ -420,9 +424,10 @@ public async Task GetModelParameters_WithDisabledMapping_ReturnsNotFound() var result = await _controller.GetModelParameters("disabled-model"); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - Assert.Contains("not found", errorResponse.error.ToString()?.ToLower() ?? ""); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Contains("not found", errorResponse.Error.Message.ToLower()); } protected override void Dispose(bool disposing) diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.CheckAndOwnership.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.CheckAndOwnership.cs index 7fa613c27..a96ecc8e7 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.CheckAndOwnership.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.CheckAndOwnership.cs @@ -1,8 +1,10 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using Moq; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Models; namespace ConduitLLM.Tests.Http.Controllers { @@ -51,7 +53,7 @@ public async Task CheckFileExists_WithExistingFile_ShouldReturnOkWithHeaders() var result = await _controller.CheckFileExists(fileId); // Assert - Assert.IsType(result); + result.Should().BeOfType(); Assert.Equal("image/png", _controller.Response.Headers["Content-Type"]); Assert.Equal("2048", _controller.Response.Headers["Content-Length"]); Assert.Equal("\"xyz789\"", _controller.Response.Headers["ETag"]); @@ -81,7 +83,7 @@ public async Task CheckFileExists_WithNonExistentFile_ShouldReturnNotFound() var result = await _controller.CheckFileExists(fileId); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -115,9 +117,11 @@ public async Task CheckFileExists_WithServiceException_ShouldReturn500() // Act var result = await _controller.CheckFileExists(fileId); - // Assert - var statusCodeResult = Assert.IsType(result); - Assert.Equal(500, statusCodeResult.StatusCode); + // Assert โ€” GatewayControllerBase returns ObjectResult with OpenAIErrorResponse for unhandled exceptions + var statusCodeResult = result.Should().BeOfType().Subject; + statusCodeResult.StatusCode.Should().Be(500); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Error.Should().NotBeNull(); } #endregion @@ -154,11 +158,11 @@ public async Task DownloadFile_WithDifferentVirtualKeyId_ShouldReturnNotFound() var result = await _controller.DownloadFile(fileId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("File not found", errorDetails.Message); - Assert.Equal("not_found", errorDetails.Type); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("File not found"); + errorDetails.Type.Should().Be("not_found"); } [Fact] @@ -181,11 +185,11 @@ public async Task DownloadFile_WithUrlBasedFileId_ShouldReturnNotFound() var result = await _controller.DownloadFile(fileId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("File not found", errorDetails.Message); - Assert.Equal("not_found", errorDetails.Type); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("File not found"); + errorDetails.Type.Should().Be("not_found"); } [Fact] @@ -204,11 +208,11 @@ public async Task DownloadFile_WithNoVirtualKeyId_ShouldReturnNotFound() var result = await _controller.DownloadFile(fileId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("File not found", errorDetails.Message); - Assert.Equal("not_found", errorDetails.Type); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("File not found"); + errorDetails.Type.Should().Be("not_found"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.ConstructorAndEdgeCases.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.ConstructorAndEdgeCases.cs index 1e461f2ca..a8aa570e5 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.ConstructorAndEdgeCases.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.ConstructorAndEdgeCases.cs @@ -1,7 +1,7 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; using ConduitLLM.Gateway.Controllers; - +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using Moq; @@ -106,7 +106,7 @@ public async Task DownloadFile_WithSpecialCharactersInFileId_ShouldHandleCorrect var result = await _controller.DownloadFile(fileId); // Assert - Assert.IsType(result); + result.Should().BeOfType(); _mockFileRetrievalService.Verify(x => x.RetrieveFileAsync(fileId, It.IsAny()), Times.Once); } @@ -154,10 +154,10 @@ public async Task DownloadFile_WithNullMetadataFields_ShouldHandleGracefully() var result = await _controller.DownloadFile(fileId); // Assert - var fileActionResult = Assert.IsType(result); - Assert.Equal("application/octet-stream", fileActionResult.ContentType); - Assert.Equal("", fileActionResult.FileDownloadName); // FileStreamResult converts null to empty string - Assert.False(_controller.Response.Headers.ContainsKey("ETag")); + var fileActionResult = result.Should().BeOfType().Subject; + fileActionResult.ContentType.Should().Be("application/octet-stream"); + fileActionResult.FileDownloadName.Should().Be(""); // FileStreamResult converts null to empty string + _controller.Response.Headers.ContainsKey("ETag").Should().BeFalse(); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.DownloadFile.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.DownloadFile.cs index 044e9e408..32f5dc120 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.DownloadFile.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.DownloadFile.cs @@ -1,9 +1,11 @@ using System.Text; using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using Moq; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Models; namespace ConduitLLM.Tests.Http.Controllers { @@ -58,10 +60,10 @@ public async Task DownloadFile_WithExistingFile_ShouldReturnFileResult() var result = await _controller.DownloadFile(fileId); // Assert - var fileActionResult = Assert.IsType(result); - Assert.Equal("text/plain", fileActionResult.ContentType); - Assert.Equal("test.txt", fileActionResult.FileDownloadName); - Assert.True(fileActionResult.EnableRangeProcessing); + var fileActionResult = result.Should().BeOfType().Subject; + fileActionResult.ContentType.Should().Be("text/plain"); + fileActionResult.FileDownloadName.Should().Be("test.txt"); + fileActionResult.EnableRangeProcessing.Should().BeTrue(); } [Fact] @@ -108,8 +110,8 @@ public async Task DownloadFile_WithInlineTrue_ShouldNotSetContentDisposition() var result = await _controller.DownloadFile(fileId, inline: true); // Assert - Assert.IsType(result); - Assert.False(_controller.Response.Headers.ContainsKey("Content-Disposition")); + result.Should().BeOfType(); + _controller.Response.Headers.ContainsKey("Content-Disposition").Should().BeFalse(); } [Fact] @@ -184,11 +186,11 @@ public async Task DownloadFile_WithNonExistentFile_ShouldReturnNotFound() var result = await _controller.DownloadFile(fileId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("File not found", errorDetails.Message); - Assert.Equal("not_found", errorDetails.Type); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("File not found"); + errorDetails.Type.Should().Be("not_found"); } [Fact] @@ -222,13 +224,12 @@ public async Task DownloadFile_WithServiceException_ShouldReturn500() // Act var result = await _controller.DownloadFile(fileId); - // Assert - var statusCodeResult = Assert.IsType(result); - Assert.Equal(500, statusCodeResult.StatusCode); - var errorResponse = Assert.IsType(statusCodeResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("An error occurred while downloading the file", errorDetails.Message); - Assert.Equal("server_error", errorDetails.Type); + // Assert โ€” GatewayControllerBase returns OpenAIErrorResponse for unhandled exceptions + var statusCodeResult = result.Should().BeOfType().Subject; + statusCodeResult.StatusCode.Should().Be(500); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Error.Should().NotBeNull(); + errorResponse.Error!.Type.Should().Be("server_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.MetadataAndUrl.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.MetadataAndUrl.cs index 7efcc0f56..1df14ad8d 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.MetadataAndUrl.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/DownloadsControllerTests.MetadataAndUrl.cs @@ -1,9 +1,11 @@ using ConduitLLM.Configuration.Entities; using ConduitLLM.Core.Interfaces; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; using Microsoft.AspNetCore.Mvc; using Moq; using ConduitLLM.Configuration.DTOs; +using ConduitLLM.Core.Models; namespace ConduitLLM.Tests.Http.Controllers { @@ -59,7 +61,7 @@ public async Task GetFileMetadata_WithExistingFile_ShouldReturnMetadata() var result = await _controller.GetFileMetadata(fileId); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value; Assert.Equal("document.pdf", response.file_name.ToString()); Assert.Equal("application/pdf", response.content_type.ToString()); @@ -93,11 +95,11 @@ public async Task GetFileMetadata_WithNonExistentFile_ShouldReturnNotFound() var result = await _controller.GetFileMetadata(fileId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("File not found", errorDetails.Message); - Assert.Equal("not_found", errorDetails.Type); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("File not found"); + errorDetails.Type.Should().Be("not_found"); } [Fact] @@ -131,12 +133,12 @@ public async Task GetFileMetadata_WithServiceException_ShouldReturn500() // Act var result = await _controller.GetFileMetadata(fileId); - // Assert - var statusCodeResult = Assert.IsType(result); - Assert.Equal(500, statusCodeResult.StatusCode); - var errorResponse = Assert.IsType(statusCodeResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("An error occurred while retrieving file metadata", errorDetails.Message); + // Assert โ€” GatewayControllerBase returns OpenAIErrorResponse for unhandled exceptions + var statusCodeResult = result.Should().BeOfType().Subject; + statusCodeResult.StatusCode.Should().Be(500); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Error.Should().NotBeNull(); + errorResponse.Error!.Type.Should().Be("server_error"); } #endregion @@ -183,7 +185,7 @@ public async Task GenerateDownloadUrl_WithValidRequest_ShouldReturnUrl() var result = await _controller.GenerateDownloadUrl(request); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value; Assert.Equal(expectedUrl, response.url.ToString()); Assert.Equal(30, (int)response.expiration_minutes); @@ -233,7 +235,7 @@ public async Task GenerateDownloadUrl_WithDefaultExpiration_ShouldUse60Minutes() var result = await _controller.GenerateDownloadUrl(request); // Assert - var okResult = Assert.IsType(result); + var okResult = result.Should().BeOfType().Subject; dynamic response = okResult.Value; Assert.Equal(60, (int)response.expiration_minutes); } @@ -261,11 +263,11 @@ public async Task GenerateDownloadUrl_WithEmptyFileId_ShouldReturnBadRequest() var result = await _controller.GenerateDownloadUrl(request); // Assert - var badRequestResult = Assert.IsType(result); - var errorResponse = Assert.IsType(badRequestResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("File ID is required", errorDetails.Message); - Assert.Equal("invalid_request_error", errorDetails.Type); + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("File ID is required"); + errorDetails.Type.Should().Be("invalid_request_error"); } [Theory] @@ -304,10 +306,10 @@ public async Task GenerateDownloadUrl_WithInvalidExpiration_ShouldReturnBadReque var result = await _controller.GenerateDownloadUrl(request); // Assert - var badRequestResult = Assert.IsType(result); - var errorResponse = Assert.IsType(badRequestResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("Expiration must be between 1 minute and 1 week", errorDetails.Message); + var badRequestResult = result.Should().BeOfType().Subject; + var errorResponse = badRequestResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("Expiration must be between 1 minute and 1 week"); } [Fact] @@ -337,11 +339,11 @@ public async Task GenerateDownloadUrl_WithNonExistentFile_ShouldReturnNotFound() var result = await _controller.GenerateDownloadUrl(request); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = Assert.IsType(notFoundResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("File not found", errorDetails.Message); - Assert.Equal("not_found", errorDetails.Type); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + var errorDetails = errorResponse.error.Should().BeOfType().Subject; + errorDetails.Message.Should().Be("File not found"); + errorDetails.Type.Should().Be("not_found"); } [Fact] @@ -381,12 +383,12 @@ public async Task GenerateDownloadUrl_WithServiceException_ShouldReturn500() // Act var result = await _controller.GenerateDownloadUrl(request); - // Assert - var statusCodeResult = Assert.IsType(result); - Assert.Equal(500, statusCodeResult.StatusCode); - var errorResponse = Assert.IsType(statusCodeResult.Value); - var errorDetails = Assert.IsType(errorResponse.error); - Assert.Equal("An error occurred while generating download URL", errorDetails.Message); + // Assert โ€” GatewayControllerBase returns OpenAIErrorResponse for unhandled exceptions + var statusCodeResult = result.Should().BeOfType().Subject; + statusCodeResult.StatusCode.Should().Be(500); + var errorResponse = statusCodeResult.Value.Should().BeOfType().Subject; + errorResponse.Error.Should().NotBeNull(); + errorResponse.Error!.Type.Should().Be("server_error"); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/ImagesControllerTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/ImagesControllerTests.cs index f1ead0737..cd7e7a27b 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/ImagesControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/ImagesControllerTests.cs @@ -3,6 +3,8 @@ using ConduitLLM.Core.Interfaces; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; + using MassTransit; using Microsoft.AspNetCore.Mvc; @@ -28,6 +30,7 @@ public class ImagesControllerTests : ControllerTestBase private readonly Mock _mockVirtualKeyService; private readonly Mock _mockMediaLifecycleService; private readonly Mock _mockHttpClientFactory; + private readonly Mock _mockErrorTrackingService; private readonly Mock _mockLLMClient; private readonly Mock _mockUrlHelper; private readonly ImagesController _controller; @@ -43,6 +46,7 @@ public ImagesControllerTests(ITestOutputHelper output) : base(output) _mockVirtualKeyService = new Mock(); _mockMediaLifecycleService = new Mock(); _mockHttpClientFactory = new Mock(); + _mockErrorTrackingService = new Mock(); _mockLLMClient = new Mock(); _mockUrlHelper = new Mock(); @@ -55,7 +59,8 @@ public ImagesControllerTests(ITestOutputHelper output) : base(output) _mockPublishEndpoint.Object, _mockVirtualKeyService.Object, _mockMediaLifecycleService.Object, - _mockHttpClientFactory.Object); + _mockHttpClientFactory.Object, + _mockErrorTrackingService.Object); // Setup default controller context _controller.ControllerContext = CreateControllerContext(); @@ -78,7 +83,7 @@ public async Task CreateImage_WithEmptyPrompt_ShouldReturnBadRequest() var result = await _controller.CreateImage(request); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; var errorResponse = badRequestResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); Assert.Equal("Prompt is required", errorResponse.Error.Message); @@ -111,7 +116,7 @@ public async Task CreateImage_WithUnsupportedModel_ShouldReturnBadRequest() var result = await _controller.CreateImage(request); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; var errorResponse = badRequestResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); Assert.Equal("Model gpt-4 does not support image generation", errorResponse.Error.Message); @@ -119,7 +124,7 @@ public async Task CreateImage_WithUnsupportedModel_ShouldReturnBadRequest() } [Fact] - public async Task CreateImage_WithServiceException_ShouldReturn500() + public async Task CreateImage_WithServiceException_ShouldPropagateToMiddleware() { // Arrange var request = new ConduitLLM.Core.Models.ImageGenerationRequest @@ -131,16 +136,10 @@ public async Task CreateImage_WithServiceException_ShouldReturn500() _mockModelMappingService.Setup(x => x.GetMappingByModelAliasAsync(It.IsAny())) .ThrowsAsync(new Exception("Service error")); - // Act - var result = await _controller.CreateImage(request); - - // Assert - var objectResult = Assert.IsType(result); - Assert.Equal(500, objectResult.StatusCode); - var errorResponse = objectResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; - Assert.NotNull(errorResponse); - Assert.Equal("An error occurred while generating images", errorResponse.Error.Message); - Assert.Equal("server_error", errorResponse.Error.Type); + // Act & Assert + // Exceptions now propagate to OpenAIErrorMiddleware for proper status code mapping + var ex = await Assert.ThrowsAsync(() => _controller.CreateImage(request)); + Assert.Equal("Service error", ex.Message); } #endregion @@ -161,7 +160,7 @@ public async Task CreateImageAsync_WithEmptyPrompt_ShouldReturnBadRequest() var result = await _controller.CreateImageAsync(request); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; var errorResponse = badRequestResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); Assert.Equal("Prompt is required", errorResponse.Error.Message); @@ -195,7 +194,7 @@ public async Task CreateImageAsync_WithModelValidationFailure_ShouldReturnBadReq var result = await _controller.CreateImageAsync(request); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; var errorResponse = badRequestResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); Assert.Equal("Model gpt-4 does not support image generation", errorResponse.Error.Message); @@ -219,7 +218,7 @@ public async Task GetGenerationStatus_WithNonExistentTask_ShouldReturnNotFound() var result = await _controller.GetGenerationStatus(taskId); // Assert - var notFoundResult = Assert.IsType(result); + var notFoundResult = result.Should().BeOfType().Subject; var errorResponse = notFoundResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); Assert.Equal("Task not found", errorResponse.Error.Message); @@ -239,7 +238,7 @@ public async Task GetGenerationStatus_WithServiceException_ShouldReturn500() var result = await _controller.GetGenerationStatus(taskId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); var errorResponse = objectResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); @@ -263,7 +262,7 @@ public async Task CancelGeneration_WithNonExistentTask_ShouldReturnNotFound() var result = await _controller.CancelGeneration(taskId); // Assert - var notFoundResult = Assert.IsType(result); + var notFoundResult = result.Should().BeOfType().Subject; var errorResponse = notFoundResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); Assert.Equal("Task not found", errorResponse.Error.Message); @@ -316,7 +315,7 @@ public async Task CancelGeneration_WithCompletedTask_ShouldReturnBadRequest() var result = await _controller.CancelGeneration(taskId); // Assert - var badRequestResult = Assert.IsType(result); + var badRequestResult = result.Should().BeOfType().Subject; var errorResponse = badRequestResult.Value as ConduitLLM.Core.Models.OpenAIErrorResponse; Assert.NotNull(errorResponse); Assert.Equal("Task has already completed", errorResponse.Error.Message); diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.CheckMediaExists.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.CheckMediaExists.cs index 06a84ee6e..70f76a718 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.CheckMediaExists.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.CheckMediaExists.cs @@ -1,5 +1,7 @@ using ConduitLLM.Core.Models; +using FluentAssertions; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -42,8 +44,8 @@ public async Task CheckMediaExists_WithExistingKey_ShouldReturnOk() var result = await _controller.CheckMediaExists(storageKey); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify headers are set Assert.Equal("image/jpeg", _controller.Response.Headers["Content-Type"]); Assert.Equal("1000", _controller.Response.Headers["Content-Length"]); @@ -62,7 +64,7 @@ public async Task CheckMediaExists_WithNonExistentKey_ShouldReturnNotFound() var result = await _controller.CheckMediaExists(storageKey); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -87,8 +89,8 @@ public async Task CheckMediaExists_WithExistingKeyButNoInfo_ShouldReturnOkWithou var result = await _controller.CheckMediaExists(storageKey); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify no headers are set when media info is null Assert.False(_controller.Response.Headers.ContainsKey("Content-Type")); Assert.False(_controller.Response.Headers.ContainsKey("Content-Length")); @@ -106,9 +108,11 @@ public async Task CheckMediaExists_WithException_ShouldReturnInternalServerError // Act var result = await _controller.CheckMediaExists(storageKey); - // Assert - var statusCodeResult = Assert.IsType(result); - Assert.Equal(500, statusCodeResult.StatusCode); + // Assert - GatewayControllerBase returns OpenAIErrorResponse via ExceptionToResponseMapper + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(500, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("server_error", errorResponse.Error.Type); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMedia.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMedia.cs index 90dff6795..72080460f 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMedia.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMedia.cs @@ -2,6 +2,8 @@ using ConduitLLM.Core.Models; +using FluentAssertions; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -48,9 +50,7 @@ public async Task GetMedia_WithValidKey_ShouldReturnFile() var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - var fileResult = result as FileStreamResult; - Assert.NotNull(fileResult); + var fileResult = result.Should().BeOfType().Subject; Assert.Equal("image/jpeg", fileResult.ContentType); Assert.Equal(contentStream, fileResult.FileStream); Assert.True(fileResult.EnableRangeProcessing); @@ -97,9 +97,7 @@ public async Task GetMedia_WithVideoAndRangeHeader_ShouldCallHandleVideoRangeReq var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - var fileResult = result as FileStreamResult; - Assert.NotNull(fileResult); + var fileResult = result.Should().BeOfType().Subject; Assert.Equal("video/mp4", fileResult.ContentType); Assert.Equal(rangedStream.Stream, fileResult.FileStream); @@ -141,8 +139,8 @@ public async Task GetMedia_WithVideoFile_ShouldSetVideoHeaders() var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify video-specific headers are set Assert.Equal("bytes", _controller.Response.Headers["Accept-Ranges"]); Assert.Equal("*", _controller.Response.Headers["Access-Control-Allow-Origin"]); @@ -163,7 +161,7 @@ public async Task GetMedia_WithNonExistentKey_ShouldReturnNotFound() var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -173,9 +171,10 @@ public async Task GetMedia_WithEmptyKey_ShouldReturnBadRequest() var result = await _controller.GetMedia(""); // Assert - Assert.IsType(result); - var badRequestResult = result as BadRequestObjectResult; - Assert.Equal("Invalid storage key", badRequestResult.Value); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Invalid storage key", errorResponse.Error.Message); } [Fact] @@ -185,9 +184,10 @@ public async Task GetMedia_WithNullKey_ShouldReturnBadRequest() var result = await _controller.GetMedia(null); // Assert - Assert.IsType(result); - var badRequestResult = result as BadRequestObjectResult; - Assert.Equal("Invalid storage key", badRequestResult.Value); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Invalid storage key", errorResponse.Error.Message); } [Fact] @@ -197,9 +197,10 @@ public async Task GetMedia_WithWhitespaceKey_ShouldReturnBadRequest() var result = await _controller.GetMedia(" "); // Assert - Assert.IsType(result); - var badRequestResult = result as BadRequestObjectResult; - Assert.Equal("Invalid storage key", badRequestResult.Value); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Invalid storage key", errorResponse.Error.Message); } [Fact] @@ -214,11 +215,11 @@ public async Task GetMedia_WithException_ShouldReturnInternalServerError() // Act var result = await _controller.GetMedia(storageKey); - // Assert - Assert.IsType(result); - var objectResult = result as ObjectResult; + // Assert - GatewayControllerBase returns OpenAIErrorResponse via ExceptionToResponseMapper + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - Assert.Equal("An error occurred while retrieving the media", objectResult.Value); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("server_error", errorResponse.Error.Type); } [Fact] @@ -254,8 +255,8 @@ public async Task GetMedia_WithValidKey_ShouldSetCacheHeaders() var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify cache headers are set Assert.Equal("public, max-age=3600", _controller.Response.Headers["Cache-Control"]); Assert.Equal($"\"{storageKey}\"", _controller.Response.Headers["ETag"]); @@ -286,7 +287,7 @@ public async Task GetMedia_WithStreamReturnedNull_ShouldReturnNotFound() var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMediaInfo.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMediaInfo.cs index 80d4c012c..6f5d6160e 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMediaInfo.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.GetMediaInfo.cs @@ -1,5 +1,7 @@ using ConduitLLM.Core.Models; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Moq; @@ -38,8 +40,7 @@ public async Task GetMediaInfo_WithValidKey_ShouldReturnMediaInfo() var result = await _controller.GetMediaInfo(storageKey); // Assert - Assert.IsType(result); - var okResult = result as OkObjectResult; + var okResult = result.Should().BeOfType().Subject; Assert.Equal(mediaInfo, okResult.Value); } @@ -56,7 +57,7 @@ public async Task GetMediaInfo_WithNonExistentKey_ShouldReturnNotFound() var result = await _controller.GetMediaInfo(storageKey); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -71,11 +72,11 @@ public async Task GetMediaInfo_WithException_ShouldReturnInternalServerError() // Act var result = await _controller.GetMediaInfo(storageKey); - // Assert - Assert.IsType(result); - var objectResult = result as ObjectResult; + // Assert - GatewayControllerBase returns OpenAIErrorResponse via ExceptionToResponseMapper + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - Assert.Equal("An error occurred while retrieving media information", objectResult.Value); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("server_error", errorResponse.Error.Type); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.VideoRange.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.VideoRange.cs index a532d2b8b..3bba360a8 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.VideoRange.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/MediaControllerTests.VideoRange.cs @@ -1,5 +1,7 @@ using ConduitLLM.Core.Models; +using FluentAssertions; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -52,8 +54,8 @@ public async Task HandleVideoRangeRequest_WithValidRange_ShouldReturnPartialCont var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify partial content status and headers Assert.Equal(206, _controller.Response.StatusCode); Assert.Equal("bytes", _controller.Response.Headers["Accept-Ranges"]); @@ -90,8 +92,7 @@ public async Task HandleVideoRangeRequest_WithInvalidRange_ShouldReturnRangeNotS var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - var objectResult = result as ObjectResult; + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(416, objectResult.StatusCode); } @@ -124,7 +125,7 @@ public async Task HandleVideoRangeRequest_WithMalformedRange_ShouldReturnBadRequ var result = await _controller.GetMedia(storageKey); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(416, objectResult.StatusCode); } @@ -160,7 +161,7 @@ public async Task HandleVideoRangeRequest_WithNonExistentVideo_ShouldReturnNotFo var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); + result.Should().BeOfType(); } [Fact] @@ -195,8 +196,7 @@ public async Task HandleVideoRangeRequest_WithException_ShouldReturnInternalServ var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - var objectResult = result as ObjectResult; + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); } @@ -251,8 +251,8 @@ public async Task ParseRangeHeader_WithValidRanges_ShouldParseCorrectly( var result = await _controller.GetMedia(storageKey); // Assert - Assert.IsType(result); - + result.Should().BeOfType(); + // Verify the correct range was requested _mockStorageService.Verify(x => x.GetVideoStreamAsync(storageKey, expectedStart, expectedEnd), Times.Once); @@ -293,7 +293,7 @@ public async Task ParseRangeHeader_WithInvalidRanges_ShouldReturn416RangeNotSati var result = await _controller.GetMedia(storageKey); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(416, objectResult.StatusCode); } @@ -325,8 +325,9 @@ public async Task ParseRangeHeader_WithEmptyRange_ShouldReturnBadRequest() // Act var result = await _controller.GetMedia(storageKey); - // Assert - Assert.IsType(result); + // Assert - OpenAIError returns ObjectResult with status 400 + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/TasksControllerTests.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/TasksControllerTests.cs index 1e0adcdcf..af33ae9e4 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/TasksControllerTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/TasksControllerTests.cs @@ -1,6 +1,9 @@ using ConduitLLM.Core.Interfaces; +using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -32,7 +35,7 @@ public TasksControllerTests(ITestOutputHelper output) : base(output) public void Constructor_WithNullTaskService_ShouldThrowArgumentNullException() { // Act & Assert - var exception = Assert.Throws(() => + var exception = Assert.Throws(() => new TasksController(null, _mockLogger.Object)); Assert.Equal("taskService", exception.ParamName); } @@ -41,7 +44,7 @@ public void Constructor_WithNullTaskService_ShouldThrowArgumentNullException() public void Constructor_WithNullLogger_ShouldThrowArgumentNullException() { // Act & Assert - var exception = Assert.Throws(() => + var exception = Assert.Throws(() => new TasksController(_mockTaskService.Object, null)); Assert.Equal("logger", exception.ParamName); } @@ -95,13 +98,12 @@ public async Task GetTaskStatus_WithNonExistentTask_ShouldReturnNotFound() var result = await _controller.GetTaskStatus(taskId); // Assert - var notFoundResult = Assert.IsType(result); + var notFoundResult = result.Should().BeOfType().Subject; Assert.NotNull(notFoundResult.Value); - - var errorResponse = notFoundResult.Value as dynamic; - Assert.NotNull(errorResponse); - Assert.Equal("Task not found", errorResponse.error.Message.ToString()); - Assert.Equal("not_found", errorResponse.error.Type.ToString()); + + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + Assert.Equal("Task not found", errorResponse.Error.Message); + Assert.Equal("not_found_error", errorResponse.Error.Type); } [Fact] @@ -116,13 +118,12 @@ public async Task GetTaskStatus_WithServiceException_ShouldReturn500() var result = await _controller.GetTaskStatus(taskId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - - var errorResponse = objectResult.Value as dynamic; - Assert.NotNull(errorResponse); - Assert.Equal("An error occurred while retrieving the task", errorResponse.error.Message.ToString()); - Assert.Equal("server_error", errorResponse.error.Type.ToString()); + + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("An unexpected error occurred", errorResponse.Error.Message); + Assert.Equal("server_error", errorResponse.Error.Type); } #endregion @@ -141,7 +142,7 @@ public async Task CancelTask_WithValidTaskId_ShouldReturnNoContent() var result = await _controller.CancelTask(taskId); // Assert - Assert.IsType(result); + result.Should().BeOfType(); _mockTaskService.Verify(x => x.CancelTaskAsync(taskId, It.IsAny()), Times.Once); } @@ -157,11 +158,10 @@ public async Task CancelTask_WithNonExistentTask_ShouldReturnNotFound() var result = await _controller.CancelTask(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = notFoundResult.Value as dynamic; - Assert.NotNull(errorResponse); - Assert.Equal("Task not found", errorResponse.error.Message.ToString()); - Assert.Equal("not_found", errorResponse.error.Type.ToString()); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + Assert.Equal("Task not found", errorResponse.Error.Message); + Assert.Equal("not_found_error", errorResponse.Error.Type); } [Fact] @@ -176,13 +176,12 @@ public async Task CancelTask_WithServiceException_ShouldReturn500() var result = await _controller.CancelTask(taskId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - - var errorResponse = objectResult.Value as dynamic; - Assert.NotNull(errorResponse); - Assert.Equal("An error occurred while cancelling the task", errorResponse.error.Message.ToString()); - Assert.Equal("server_error", errorResponse.error.Type.ToString()); + + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("An unexpected error occurred", errorResponse.Error.Message); + Assert.Equal("server_error", errorResponse.Error.Type); } #endregion @@ -203,8 +202,8 @@ public async Task PollTask_WithValidTaskId_ShouldReturnCompletedStatus() }; _mockTaskService.Setup(x => x.PollTaskUntilCompletedAsync( - taskId, - It.IsAny(), + taskId, + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(expectedStatus); @@ -227,7 +226,7 @@ public async Task PollTask_WithCustomTimeoutAndInterval_ShouldUseClampedValues() var taskId = "task-123"; var timeout = 700; // Above max, should be clamped to 600 var interval = 0; // Below min, should be clamped to 1 - + _mockTaskService.Setup(x => x.PollTaskUntilCompletedAsync( taskId, TimeSpan.FromSeconds(1), // Clamped interval @@ -239,7 +238,7 @@ public async Task PollTask_WithCustomTimeoutAndInterval_ShouldUseClampedValues() var result = await _controller.PollTask(taskId, timeout, interval); // Assert - Assert.IsType(result); + result.Should().BeOfType(); _mockTaskService.Verify(x => x.PollTaskUntilCompletedAsync( taskId, TimeSpan.FromSeconds(1), @@ -263,13 +262,12 @@ public async Task PollTask_WithTimeout_ShouldReturn408() var result = await _controller.PollTask(taskId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(408, objectResult.StatusCode); - - var errorResponse = objectResult.Value as dynamic; - Assert.NotNull(errorResponse); - Assert.Equal("Task polling timed out", errorResponse.error.Message.ToString()); - Assert.Equal("timeout", errorResponse.error.Type.ToString()); + + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Task polling timed out", errorResponse.Error.Message); + Assert.Equal("timeout", errorResponse.Error.Type); } [Fact] @@ -288,10 +286,9 @@ public async Task PollTask_WithNonExistentTask_ShouldReturnNotFound() var result = await _controller.PollTask(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var errorResponse = notFoundResult.Value as dynamic; - Assert.NotNull(errorResponse); - Assert.Equal("Task not found", errorResponse.error.Message.ToString()); + var notFoundResult = result.Should().BeOfType().Subject; + var errorResponse = notFoundResult.Value.Should().BeOfType().Subject; + Assert.Equal("Task not found", errorResponse.Error.Message); } [Fact] @@ -310,12 +307,11 @@ public async Task PollTask_WithServiceException_ShouldReturn500() var result = await _controller.PollTask(taskId); // Assert - var objectResult = Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; Assert.Equal(500, objectResult.StatusCode); - - var errorResponse = objectResult.Value as dynamic; - Assert.NotNull(errorResponse); - Assert.Equal("An error occurred while polling the task", errorResponse.error.Message.ToString()); + + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("An unexpected error occurred", errorResponse.Error.Message); } #endregion @@ -333,4 +329,4 @@ public void Controller_ShouldRequireAuthorization() } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Authorization.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Authorization.cs index 73f670ff5..1de04a3dc 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Authorization.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Authorization.cs @@ -18,16 +18,16 @@ public void Controller_ShouldRequireAuthorization() } [Fact] - public void Controller_ShouldHaveRateLimiting() + public void Controller_ShouldNotCarryFrameworkRateLimitingAttribute() { - // Arrange & Act + // Rate limiting is enforced by VirtualKeyRateLimitMiddleware against the + // Redis-backed IVirtualKeyRateLimitService, not the framework rate limiter. + // This test guards against a future regression that re-adds the attribute, + // which would silently route through the deleted no-op policy. var controllerType = typeof(VideosController); var rateLimitAttribute = Attribute.GetCustomAttribute(controllerType, typeof(Microsoft.AspNetCore.RateLimiting.EnableRateLimitingAttribute)); - // Assert - Assert.NotNull(rateLimitAttribute); - var attr = (Microsoft.AspNetCore.RateLimiting.EnableRateLimitingAttribute)rateLimitAttribute; - Assert.Equal("VirtualKeyPolicy", attr.PolicyName); + Assert.Null(rateLimitAttribute); } #endregion diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.GenerateVideo.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.GenerateVideo.cs index a515ec35e..1484bf932 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.GenerateVideo.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.GenerateVideo.cs @@ -1,7 +1,11 @@ using ConduitLLM.Core.Constants; +using ConduitLLM.Core.Events; +using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Moq; @@ -26,22 +30,13 @@ public async Task GenerateVideoAsync_WithValidRequest_ShouldReturnAccepted() var virtualKey = "condt_test_key_123456"; var taskId = "task-video-123"; - - var videoResponse = new VideoGenerationResponse - { - Data = new List - { - new VideoData { Url = $"pending:{taskId}" } - } - }; - _mockVideoService.Setup(x => x.GenerateVideoWithTaskAsync( - It.IsAny(), - virtualKey, + _mockTaskService.Setup(x => x.CreateTaskAsync( + "video_generation", + 123, + It.IsAny(), It.IsAny())) - .ReturnsAsync(videoResponse); - - // Token generation removed - using ephemeral keys + .ReturnsAsync(taskId); _controller.ControllerContext = CreateControllerContext(); _controller.ControllerContext.HttpContext.Items["VirtualKey"] = virtualKey; @@ -55,75 +50,82 @@ public async Task GenerateVideoAsync_WithValidRequest_ShouldReturnAccepted() var result = await _controller.GenerateVideoAsync(request); // Assert - var acceptedResult = Assert.IsType(result); - var taskResponse = Assert.IsType(acceptedResult.Value); + var acceptedResult = result.Should().BeOfType().Subject; + var taskResponse = acceptedResult.Value.Should().BeOfType().Subject; Assert.Equal(taskId, taskResponse.TaskId); Assert.Equal(TaskStateConstants.Pending, taskResponse.Status); Assert.Contains(taskId, taskResponse.CheckStatusUrl); - // SignalRToken removed - clients use ephemeral keys _mockTaskRegistry.Verify(x => x.RegisterTask(taskId, It.IsAny()), Times.Once); + _mockPublishEndpoint.Verify(x => x.Publish( + It.IsAny(), + It.IsAny()), Times.Once); } [Fact] - public async Task GenerateVideoAsync_WithInvalidModelState_ShouldReturnBadRequest() + public async Task GenerateVideoAsync_WithoutVirtualKey_ShouldReturnUnauthorized() { // Arrange var request = new VideoGenerationRequest { - Prompt = "", // Empty prompt to trigger validation + Prompt = "A beautiful sunset", Model = "runway-ml" }; - _controller.ModelState.AddModelError("Prompt", "Prompt is required"); + + _controller.ControllerContext = CreateControllerContext(); // Act var result = await _controller.GenerateVideoAsync(request); // Assert - Assert.IsType(result); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Virtual key not found in request context", errorResponse.Error.Message); } [Fact] - public async Task GenerateVideoAsync_WithoutVirtualKey_ShouldReturnUnauthorized() + public async Task GenerateVideoAsync_WithEmptyPrompt_ShouldReturnBadRequest() { // Arrange var request = new VideoGenerationRequest { - Prompt = "A beautiful sunset", + Prompt = "", Model = "runway-ml" }; _controller.ControllerContext = CreateControllerContext(); + _controller.ControllerContext.HttpContext.Items["VirtualKey"] = "condt_test_key"; + _controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity(new[] + { + new System.Security.Claims.Claim("VirtualKeyId", "123") + }, "Test")); // Act var result = await _controller.GenerateVideoAsync(request); // Assert - var unauthorizedResult = Assert.IsType(result); - var problemDetails = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Unauthorized", problemDetails.Title); - Assert.Equal("Virtual key not found in request context", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Prompt is required", errorResponse.Error.Message); + _mockTaskService.Verify(x => x.CreateTaskAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); } [Fact] - public async Task GenerateVideoAsync_WithArgumentException_ShouldReturnBadRequest() + public async Task GenerateVideoAsync_WithEmptyModel_ShouldReturnBadRequest() { // Arrange var request = new VideoGenerationRequest { Prompt = "Test prompt", - Model = "invalid-model" + Model = "" }; - var virtualKey = "condt_test_key_123456"; - - _mockVideoService.Setup(x => x.GenerateVideoWithTaskAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ThrowsAsync(new ArgumentException("Invalid model specified")); - _controller.ControllerContext = CreateControllerContext(); - _controller.ControllerContext.HttpContext.Items["VirtualKey"] = virtualKey; + _controller.ControllerContext.HttpContext.Items["VirtualKey"] = "condt_test_key"; _controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( new System.Security.Claims.ClaimsIdentity(new[] { @@ -134,32 +136,25 @@ public async Task GenerateVideoAsync_WithArgumentException_ShouldReturnBadReques var result = await _controller.GenerateVideoAsync(request); // Assert - var badRequestResult = Assert.IsType(result); - var problemDetails = Assert.IsType(badRequestResult.Value); - Assert.Equal("Invalid Request", problemDetails.Title); - Assert.Equal("Invalid model specified", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Model is required", errorResponse.Error.Message); } [Fact] - public async Task GenerateVideoAsync_WithUnauthorizedAccessException_ShouldReturnForbidden() + public async Task GenerateVideoAsync_WithInvalidDuration_ShouldReturnBadRequest() { // Arrange var request = new VideoGenerationRequest { Prompt = "Test prompt", - Model = "runway-ml" + Model = "runway-ml", + Duration = 999 }; - var virtualKey = "condt_test_key_123456"; - - _mockVideoService.Setup(x => x.GenerateVideoWithTaskAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ThrowsAsync(new UnauthorizedAccessException("Virtual key does not have permission")); - _controller.ControllerContext = CreateControllerContext(); - _controller.ControllerContext.HttpContext.Items["VirtualKey"] = virtualKey; + _controller.ControllerContext.HttpContext.Items["VirtualKey"] = "condt_test_key"; _controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( new System.Security.Claims.ClaimsIdentity(new[] { @@ -170,32 +165,25 @@ public async Task GenerateVideoAsync_WithUnauthorizedAccessException_ShouldRetur var result = await _controller.GenerateVideoAsync(request); // Assert - var forbiddenResult = Assert.IsType(result); - Assert.Equal(403, forbiddenResult.StatusCode); - var problemDetails = Assert.IsType(forbiddenResult.Value); - Assert.Equal("Forbidden", problemDetails.Title); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Contains("Duration must be between", errorResponse.Error.Message); } [Fact] - public async Task GenerateVideoAsync_WithNotSupportedException_ShouldReturnBadRequest() + public async Task GenerateVideoAsync_WithInvalidFps_ShouldReturnBadRequest() { // Arrange var request = new VideoGenerationRequest { Prompt = "Test prompt", - Model = "text-only-model" + Model = "runway-ml", + Fps = 999 }; - var virtualKey = "condt_test_key_123456"; - - _mockVideoService.Setup(x => x.GenerateVideoWithTaskAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ThrowsAsync(new NotSupportedException("Model does not support video generation")); - _controller.ControllerContext = CreateControllerContext(); - _controller.ControllerContext.HttpContext.Items["VirtualKey"] = virtualKey; + _controller.ControllerContext.HttpContext.Items["VirtualKey"] = "condt_test_key"; _controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( new System.Security.Claims.ClaimsIdentity(new[] { @@ -206,10 +194,10 @@ public async Task GenerateVideoAsync_WithNotSupportedException_ShouldReturnBadRe var result = await _controller.GenerateVideoAsync(request); // Assert - var badRequestResult = Assert.IsType(result); - var problemDetails = Assert.IsType(badRequestResult.Value); - Assert.Equal("Not Supported", problemDetails.Title); - Assert.Equal("Model does not support video generation", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Contains("FPS must be between", errorResponse.Error.Message); } [Fact] @@ -222,16 +210,15 @@ public async Task GenerateVideoAsync_WithGeneralException_ShouldReturn500() Model = "runway-ml" }; - var virtualKey = "condt_test_key_123456"; - - _mockVideoService.Setup(x => x.GenerateVideoWithTaskAsync( - It.IsAny(), + _mockTaskService.Setup(x => x.CreateTaskAsync( It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("Internal error")); _controller.ControllerContext = CreateControllerContext(); - _controller.ControllerContext.HttpContext.Items["VirtualKey"] = virtualKey; + _controller.ControllerContext.HttpContext.Items["VirtualKey"] = "condt_test_key"; _controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( new System.Security.Claims.ClaimsIdentity(new[] { @@ -242,12 +229,12 @@ public async Task GenerateVideoAsync_WithGeneralException_ShouldReturn500() var result = await _controller.GenerateVideoAsync(request); // Assert - var internalServerErrorResult = Assert.IsType(result); - Assert.Equal(500, internalServerErrorResult.StatusCode); - var problemDetails = Assert.IsType(internalServerErrorResult.Value); - Assert.Equal("Internal Server Error", problemDetails.Title); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(500, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("server_error", errorResponse.Error.Type); } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Security.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Security.cs index 594481208..49eecd186 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Security.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Security.cs @@ -3,6 +3,8 @@ using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Moq; @@ -19,7 +21,7 @@ public async Task GetTaskStatus_WhenUserDoesNotOwnTask_ShouldReturn404() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -47,11 +49,11 @@ public async Task GetTaskStatus_WhenUserDoesNotOwnTask_ShouldReturn404() var result = await _controller.GetTaskStatus(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var problemDetails = Assert.IsType(notFoundResult.Value); - Assert.Equal("Task Not Found", problemDetails.Title); - Assert.Equal("The requested task was not found", problemDetails.Detail); - + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("The requested task was not found", errorResponse.Error.Message); + // Verify security logging _mockLogger.Verify(x => x.Log( Microsoft.Extensions.Logging.LogLevel.Warning, @@ -67,7 +69,7 @@ public async Task GetTaskStatus_WithNullMetadata_ShouldReturn404() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -95,9 +97,10 @@ public async Task GetTaskStatus_WithNullMetadata_ShouldReturn404() var result = await _controller.GetTaskStatus(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var problemDetails = Assert.IsType(notFoundResult.Value); - Assert.Equal("Task Not Found", problemDetails.Title); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("The requested task was not found", errorResponse.Error.Message); } [Fact] @@ -119,10 +122,10 @@ public async Task GetTaskStatus_WithInvalidVirtualKeyId_ShouldReturn401() var result = await _controller.GetTaskStatus(taskId); // Assert - var unauthorizedResult = Assert.IsType(result); - var problemDetails = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Unauthorized", problemDetails.Title); - Assert.Equal("Virtual key not found in request context", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Virtual key not found in request context", errorResponse.Error.Message); } [Fact] @@ -131,7 +134,7 @@ public async Task RetryTask_WhenUserDoesNotOwnTask_ShouldReturn404() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -159,11 +162,11 @@ public async Task RetryTask_WhenUserDoesNotOwnTask_ShouldReturn404() var result = await _controller.RetryTask(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var problemDetails = Assert.IsType(notFoundResult.Value); - Assert.Equal("Task Not Found", problemDetails.Title); - Assert.Equal("The requested task was not found", problemDetails.Detail); - + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("The requested task was not found", errorResponse.Error.Message); + // Verify security logging _mockLogger.Verify(x => x.Log( Microsoft.Extensions.Logging.LogLevel.Warning, @@ -179,7 +182,7 @@ public async Task CancelTask_WhenUserDoesNotOwnTask_ShouldReturn404() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -204,11 +207,11 @@ public async Task CancelTask_WhenUserDoesNotOwnTask_ShouldReturn404() var result = await _controller.CancelTask(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var problemDetails = Assert.IsType(notFoundResult.Value); - Assert.Equal("Task Not Found", problemDetails.Title); - Assert.Equal("The requested task was not found", problemDetails.Detail); - + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("The requested task was not found", errorResponse.Error.Message); + // Verify security logging _mockLogger.Verify(x => x.Log( Microsoft.Extensions.Logging.LogLevel.Warning, @@ -224,7 +227,7 @@ public async Task RetryTask_WithValidOwnership_ShouldAllowRetry() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var failedTaskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -277,8 +280,8 @@ public async Task RetryTask_WithValidOwnership_ShouldAllowRetry() var result = await _controller.RetryTask(taskId); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; Assert.Equal(taskId, response.TaskId); Assert.Equal(TaskStateConstants.Pending, response.Status); } @@ -289,7 +292,7 @@ public async Task CancelTask_WithValidOwnership_ShouldAllowCancellation() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -305,9 +308,6 @@ public async Task CancelTask_WithValidOwnership_ShouldAllowCancellation() _mockTaskRegistry.Setup(x => x.TryCancel(taskId)) .Returns(true); - _mockVideoService.Setup(x => x.CancelVideoGenerationAsync(taskId, virtualKey, It.IsAny())) - .ReturnsAsync(true); - _mockTaskService.Setup(x => x.CancelTaskAsync(taskId, It.IsAny())) .Returns(Task.CompletedTask); @@ -323,12 +323,11 @@ public async Task CancelTask_WithValidOwnership_ShouldAllowCancellation() var result = await _controller.CancelTask(taskId); // Assert - Assert.IsType(result); + result.Should().BeOfType(); _mockTaskRegistry.Verify(x => x.TryCancel(taskId), Times.Once); - _mockVideoService.Verify(x => x.CancelVideoGenerationAsync(taskId, virtualKey, It.IsAny()), Times.Once); _mockTaskService.Verify(x => x.CancelTaskAsync(taskId, It.IsAny()), Times.Once); } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Setup.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Setup.cs index 2ba914731..eecdf3c8e 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Setup.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.Setup.cs @@ -1,6 +1,8 @@ using ConduitLLM.Core.Interfaces; using ConduitLLM.Gateway.Controllers; +using MassTransit; + using Microsoft.Extensions.Logging; using Moq; @@ -14,29 +16,29 @@ namespace ConduitLLM.Tests.Http.Controllers [Trait("Phase", "2")] public partial class VideosControllerTests : ControllerTestBase { - private readonly Mock _mockVideoService; private readonly Mock _mockTaskService; private readonly Mock _mockTimeoutProvider; private readonly Mock _mockTaskRegistry; + private readonly Mock _mockPublishEndpoint; private readonly Mock> _mockLogger; private readonly VideosController _controller; public VideosControllerTests(ITestOutputHelper output) : base(output) { - _mockVideoService = new Mock(); _mockTaskService = new Mock(); _mockTimeoutProvider = new Mock(); _mockTaskRegistry = new Mock(); + _mockPublishEndpoint = new Mock(); _mockLogger = CreateLogger(); var mockModelMappingService = new Mock(); _controller = new VideosController( - _mockVideoService.Object, _mockTaskService.Object, _mockTimeoutProvider.Object, _mockTaskRegistry.Object, _mockLogger.Object, - mockModelMappingService.Object); + mockModelMappingService.Object, + _mockPublishEndpoint.Object); // Setup default controller context _controller.ControllerContext = CreateControllerContext(); diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskCancel.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskCancel.cs index 8bb33ff91..23f4f4df2 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskCancel.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskCancel.cs @@ -1,6 +1,9 @@ +using ConduitLLM.Core.Events; using ConduitLLM.Core.Interfaces; using ConduitLLM.Core.Models; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Moq; @@ -17,7 +20,7 @@ public async Task CancelTask_WithPendingTask_ShouldReturnNoContent() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -33,9 +36,6 @@ public async Task CancelTask_WithPendingTask_ShouldReturnNoContent() _mockTaskRegistry.Setup(x => x.TryCancel(taskId)) .Returns(true); - _mockVideoService.Setup(x => x.CancelVideoGenerationAsync(taskId, virtualKey, It.IsAny())) - .ReturnsAsync(true); - _mockTaskService.Setup(x => x.CancelTaskAsync(taskId, It.IsAny())) .Returns(Task.CompletedTask); @@ -51,10 +51,12 @@ public async Task CancelTask_WithPendingTask_ShouldReturnNoContent() var result = await _controller.CancelTask(taskId); // Assert - Assert.IsType(result); + result.Should().BeOfType(); _mockTaskRegistry.Verify(x => x.TryCancel(taskId), Times.Once); - _mockVideoService.Verify(x => x.CancelVideoGenerationAsync(taskId, virtualKey, It.IsAny()), Times.Once); _mockTaskService.Verify(x => x.CancelTaskAsync(taskId, It.IsAny()), Times.Once); + _mockPublishEndpoint.Verify(x => x.Publish( + It.IsAny(), + It.IsAny()), Times.Once); } [Fact] @@ -63,7 +65,7 @@ public async Task CancelTask_WithCompletedTask_ShouldReturnConflict() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -88,10 +90,11 @@ public async Task CancelTask_WithCompletedTask_ShouldReturnConflict() var result = await _controller.CancelTask(taskId); // Assert - var conflictResult = Assert.IsType(result); - var problemDetails = Assert.IsType(conflictResult.Value); - Assert.Equal("Cannot Cancel Task", problemDetails.Title); - Assert.Contains("already completed", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(409, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Contains("already completed", errorResponse.Error.Message); + _mockTaskService.Verify(x => x.CancelTaskAsync(taskId, It.IsAny()), Times.Never); } [Fact] @@ -116,53 +119,12 @@ public async Task CancelTask_WithNonExistentTask_ShouldReturnNotFound() var result = await _controller.CancelTask(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var problemDetails = Assert.IsType(notFoundResult.Value); - Assert.Equal("Task Not Found", problemDetails.Title); - } - - [Fact] - public async Task CancelTask_WhenCancellationFails_ShouldReturnConflict() - { - // Arrange - var taskId = "task-video-123"; - var virtualKey = "condt_test_key_123456"; - - var taskStatus = new AsyncTaskStatus - { - TaskId = taskId, - State = TaskState.Processing, - CreatedAt = DateTime.UtcNow.AddMinutes(-5), - UpdatedAt = DateTime.UtcNow, - Metadata = new TaskMetadata(123) - }; - - _mockTaskService.Setup(x => x.GetTaskStatusAsync(taskId, It.IsAny())) - .ReturnsAsync(taskStatus); - - _mockTaskRegistry.Setup(x => x.TryCancel(taskId)) - .Returns(false); - - _mockVideoService.Setup(x => x.CancelVideoGenerationAsync(taskId, virtualKey, It.IsAny())) - .ReturnsAsync(false); - - _controller.ControllerContext = CreateControllerContext(); - _controller.ControllerContext.HttpContext.Items["VirtualKey"] = virtualKey; - _controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( - new System.Security.Claims.ClaimsIdentity(new[] - { - new System.Security.Claims.Claim("VirtualKeyId", "123") - }, "Test")); - - // Act - var result = await _controller.CancelTask(taskId); - - // Assert - var conflictResult = Assert.IsType(result); - var problemDetails = Assert.IsType(conflictResult.Value); - Assert.Equal("Cancellation Failed", problemDetails.Title); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("The requested task was not found", errorResponse.Error.Message); } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskRetry.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskRetry.cs index 2796514a6..393e7a720 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskRetry.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskRetry.cs @@ -3,6 +3,8 @@ using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Moq; @@ -19,7 +21,7 @@ public async Task RetryTask_WithFailedTask_ShouldReturnOk() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var failedTaskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -72,8 +74,8 @@ public async Task RetryTask_WithFailedTask_ShouldReturnOk() var result = await _controller.RetryTask(taskId); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; Assert.Equal(taskId, response.TaskId); Assert.Equal(TaskStateConstants.Pending, response.Status); Assert.Contains("Retry", response.Error); @@ -85,7 +87,7 @@ public async Task RetryTask_WithNonFailedTask_ShouldReturnBadRequest() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -110,10 +112,10 @@ public async Task RetryTask_WithNonFailedTask_ShouldReturnBadRequest() var result = await _controller.RetryTask(taskId); // Assert - var badRequestResult = Assert.IsType(result); - var problemDetails = Assert.IsType(badRequestResult.Value); - Assert.Equal("Invalid Task State", problemDetails.Title); - Assert.Contains("failed tasks can be retried", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Contains("Only failed tasks can be retried", errorResponse.Error.Message); } [Fact] @@ -122,7 +124,7 @@ public async Task RetryTask_WithNonRetryableTask_ShouldReturnBadRequest() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -148,9 +150,10 @@ public async Task RetryTask_WithNonRetryableTask_ShouldReturnBadRequest() var result = await _controller.RetryTask(taskId); // Assert - var badRequestResult = Assert.IsType(result); - var problemDetails = Assert.IsType(badRequestResult.Value); - Assert.Equal("Task Not Retryable", problemDetails.Title); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("This task has been marked as non-retryable", errorResponse.Error.Message); } [Fact] @@ -159,7 +162,7 @@ public async Task RetryTask_WithMaxRetriesExceeded_ShouldReturnBadRequest() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -187,12 +190,12 @@ public async Task RetryTask_WithMaxRetriesExceeded_ShouldReturnBadRequest() var result = await _controller.RetryTask(taskId); // Assert - var badRequestResult = Assert.IsType(result); - var problemDetails = Assert.IsType(badRequestResult.Value); - Assert.Equal("Max Retries Exceeded", problemDetails.Title); - Assert.Contains("already been retried", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(400, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Contains("already been retried", errorResponse.Error.Message); } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskStatus.cs b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskStatus.cs index 9b6c7f4e6..0c24fa905 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskStatus.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Controllers/VideosControllerTests.TaskStatus.cs @@ -3,6 +3,8 @@ using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Controllers; +using FluentAssertions; + using Microsoft.AspNetCore.Mvc; using Moq; @@ -19,7 +21,15 @@ public async Task GetTaskStatus_WithValidTaskId_ShouldReturnOk() // Arrange var taskId = "task-video-123"; var virtualKey = "condt_test_key_123456"; - + + var videoResponse = new VideoGenerationResponse + { + Data = new List + { + new VideoData { Url = "https://example.com/video.mp4" } + } + }; + var taskStatus = new AsyncTaskStatus { TaskId = taskId, @@ -28,24 +38,13 @@ public async Task GetTaskStatus_WithValidTaskId_ShouldReturnOk() CreatedAt = DateTime.UtcNow.AddMinutes(-5), UpdatedAt = DateTime.UtcNow, CompletedAt = DateTime.UtcNow, - Result = "video-url-123", + Result = videoResponse, // Stored result is deserialized inline by the controller Metadata = new TaskMetadata(123) // Same virtual key ID as in claims }; - var videoResponse = new VideoGenerationResponse - { - Data = new List - { - new VideoData { Url = "https://example.com/video.mp4" } - } - }; - _mockTaskService.Setup(x => x.GetTaskStatusAsync(taskId, It.IsAny())) .ReturnsAsync(taskStatus); - _mockVideoService.Setup(x => x.GetVideoGenerationStatusAsync(taskId, virtualKey, It.IsAny())) - .ReturnsAsync(videoResponse); - _controller.ControllerContext = CreateControllerContext(); _controller.ControllerContext.HttpContext.Items["VirtualKey"] = virtualKey; _controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( @@ -58,12 +57,12 @@ public async Task GetTaskStatus_WithValidTaskId_ShouldReturnOk() var result = await _controller.GetTaskStatus(taskId); // Assert - var okResult = Assert.IsType(result); - var response = Assert.IsType(okResult.Value); + var okResult = result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; Assert.Equal(taskId, response.TaskId); Assert.Equal(TaskStateConstants.Completed, response.Status); Assert.Equal(100, response.Progress); - Assert.NotNull(response.VideoResponse); + Assert.NotNull(response.Result); } [Fact] @@ -88,10 +87,10 @@ public async Task GetTaskStatus_WithNonExistentTask_ShouldReturnNotFound() var result = await _controller.GetTaskStatus(taskId); // Assert - var notFoundResult = Assert.IsType(result); - var problemDetails = Assert.IsType(notFoundResult.Value); - Assert.Equal("Task Not Found", problemDetails.Title); - Assert.Equal("The requested task was not found", problemDetails.Detail); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(404, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("The requested task was not found", errorResponse.Error.Message); } [Fact] @@ -105,9 +104,10 @@ public async Task GetTaskStatus_WithoutVirtualKey_ShouldReturnUnauthorized() var result = await _controller.GetTaskStatus(taskId); // Assert - var unauthorizedResult = Assert.IsType(result); - var problemDetails = Assert.IsType(unauthorizedResult.Value); - Assert.Equal("Unauthorized", problemDetails.Title); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(401, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("Virtual key not found in request context", errorResponse.Error.Message); } [Fact] @@ -132,12 +132,12 @@ public async Task GetTaskStatus_WithException_ShouldReturn500() var result = await _controller.GetTaskStatus(taskId); // Assert - var internalServerErrorResult = Assert.IsType(result); - Assert.Equal(500, internalServerErrorResult.StatusCode); - var problemDetails = Assert.IsType(internalServerErrorResult.Value); - Assert.Equal("Internal Server Error", problemDetails.Title); + var objectResult = result.Should().BeOfType().Subject; + Assert.Equal(500, objectResult.StatusCode); + var errorResponse = objectResult.Value.Should().BeOfType().Subject; + Assert.Equal("An unexpected error occurred", errorResponse.Error.Message); } #endregion } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/EventHandlers/ResilientEventHandlerBaseTests.cs b/Tests/ConduitLLM.Tests/Gateway/EventHandlers/ResilientEventHandlerBaseTests.cs deleted file mode 100644 index 617a34828..000000000 --- a/Tests/ConduitLLM.Tests/Gateway/EventHandlers/ResilientEventHandlerBaseTests.cs +++ /dev/null @@ -1,807 +0,0 @@ -using ConduitLLM.Gateway.EventHandlers; - -using FluentAssertions; - -using MassTransit; - -using Microsoft.Extensions.Logging; - -using Moq; - -using Polly.CircuitBreaker; - -using Xunit.Abstractions; - -namespace ConduitLLM.Tests.Http.EventHandlers -{ - /// - /// Unit tests for ResilientEventHandlerBase resilience patterns. - /// Tests circuit breaker, retry, timeout, and fallback mechanisms. - /// - [Trait("Category", "Unit")] - [Trait("Component", "EventHandlers")] - [Trait("Feature", "Resilience")] - public class ResilientEventHandlerBaseTests : TestBase - { - private readonly Mock> _loggerMock; - - public ResilientEventHandlerBaseTests(ITestOutputHelper output) : base(output) - { - _loggerMock = CreateLogger(); - } - - #region Helper Classes - - /// - /// Simple test event for use in resilience tests - /// - public class TestEvent - { - public string EventId { get; set; } = Guid.NewGuid().ToString(); - public string Data { get; set; } = "Test data"; - } - - /// - /// Configuration for test handler - must be set BEFORE construction - /// to work around virtual method timing in base constructor. - /// - public class TestHandlerConfig - { - public int RetryCount { get; set; } = 3; - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); - public int CircuitBreakerThreshold { get; set; } = 5; - public TimeSpan CircuitBreakerDuration { get; set; } = TimeSpan.FromMinutes(1); - } - - /// - /// Concrete test implementation of ResilientEventHandlerBase for testing. - /// Note: Configuration must be passed before base constructor runs, - /// so we use a static config pattern or accept defaults. - /// - public class TestResilientEventHandler : ResilientEventHandlerBase - { - public Func? HandleAction { get; set; } - public Func? FallbackAction { get; set; } - public Func? TransientExceptionCheck { get; set; } - - // Counters for verification - public int HandleCallCount { get; private set; } - public int FallbackCallCount { get; private set; } - - // Callback tracking for circuit breaker events - public List CircuitBreakerEvents { get; } = new(); - - // Configuration - these are read at construction time via virtual methods - private readonly TestHandlerConfig _config; - - public TestResilientEventHandler(ILogger logger) - : this(logger, new TestHandlerConfig()) - { - } - - public TestResilientEventHandler(ILogger logger, TestHandlerConfig config) - : base(logger) - { - _config = config ?? new TestHandlerConfig(); - } - - // Static factory for pre-configured handlers - public static TestResilientEventHandler Create( - ILogger logger, - int? retryCount = null, - TimeSpan? timeout = null, - int? circuitBreakerThreshold = null, - TimeSpan? circuitBreakerDuration = null) - { - // Note: Due to C# virtual method timing, custom config here won't work - // for values read during base constructor. Tests should use defaults - // or verify override behavior separately. - return new TestResilientEventHandler(logger, new TestHandlerConfig - { - RetryCount = retryCount ?? 3, - Timeout = timeout ?? TimeSpan.FromSeconds(30), - CircuitBreakerThreshold = circuitBreakerThreshold ?? 5, - CircuitBreakerDuration = circuitBreakerDuration ?? TimeSpan.FromMinutes(1) - }); - } - - protected override async Task HandleEventAsync(TestEvent message, CancellationToken ct) - { - HandleCallCount++; - if (HandleAction != null) - await HandleAction(message, ct); - } - - protected override async Task HandleEventFallbackAsync(TestEvent message, CancellationToken ct) - { - FallbackCallCount++; - if (FallbackAction != null) - await FallbackAction(message, ct); - else - await base.HandleEventFallbackAsync(message, ct); - } - - protected override bool IsTransientException(Exception ex) - { - if (TransientExceptionCheck != null) - return TransientExceptionCheck(ex); - return base.IsTransientException(ex); - } - - // Note: These are called during base constructor, so _config may be null at that point - // The base class uses defaults when these are called during construction - protected override int GetRetryCount() => _config?.RetryCount ?? base.GetRetryCount(); - protected override TimeSpan GetTimeout() => _config?.Timeout ?? base.GetTimeout(); - protected override int GetCircuitBreakerThreshold() => _config?.CircuitBreakerThreshold ?? base.GetCircuitBreakerThreshold(); - protected override TimeSpan GetCircuitBreakerDuration() => _config?.CircuitBreakerDuration ?? base.GetCircuitBreakerDuration(); - - protected override void OnCircuitBreakerOpen(Exception? exception, TimeSpan duration) - { - CircuitBreakerEvents.Add($"Open:{exception?.Message}:{duration.TotalMilliseconds}ms"); - base.OnCircuitBreakerOpen(exception, duration); - } - - protected override void OnCircuitBreakerReset() - { - CircuitBreakerEvents.Add("Reset"); - base.OnCircuitBreakerReset(); - } - - protected override void OnCircuitBreakerHalfOpen() - { - CircuitBreakerEvents.Add("HalfOpen"); - base.OnCircuitBreakerHalfOpen(); - } - - // Expose circuit state for testing - public CircuitState CurrentCircuitState => CircuitState; - } - - #endregion - - #region Helper Methods - - private static Mock> CreateMockContext(TestEvent? @event = null) - { - var mock = new Mock>(); - mock.Setup(x => x.Message).Returns(@event ?? new TestEvent()); - mock.Setup(x => x.CancellationToken).Returns(CancellationToken.None); - return mock; - } - - private TestResilientEventHandler CreateHandler() - { - return new TestResilientEventHandler(_loggerMock.Object); - } - - #endregion - - #region Constructor & Configuration Tests - - [Fact] - public void Constructor_WithNullLogger_ThrowsArgumentNullException() - { - // Act - var act = () => new TestResilientEventHandler(null!); - - // Assert - act.Should().Throw() - .WithParameterName("logger"); - } - - [Theory] - [InlineData(typeof(TimeoutException), true)] - [InlineData(typeof(TaskCanceledException), true)] - [InlineData(typeof(InvalidOperationException), false)] - [InlineData(typeof(ArgumentException), false)] - public async Task IsTransientException_ClassifiesExceptionsCorrectly(Type exceptionType, bool expectedTransient) - { - // Arrange - var handler = CreateHandler(); - // Override to not retry to speed up test - handler.TransientExceptionCheck = _ => false; - - var exception = (Exception)Activator.CreateInstance(exceptionType, "Test exception")!; - var wasTransient = false; - - // We test by checking if base.IsTransientException would classify correctly - handler.TransientExceptionCheck = ex => - { - // Call internal method to check classification - wasTransient = ex is TimeoutException || ex is TaskCanceledException; - return false; // Don't actually retry - }; - - handler.HandleAction = (_, _) => throw exception; - - var context = CreateMockContext(); - - // Act - try - { - await handler.Consume(context.Object); - } - catch - { - // Expected - } - - // Assert - wasTransient.Should().Be(expectedTransient); - } - - [Fact] - public async Task IsTransientException_DetectsNestedTransientException() - { - // Arrange - var handler = CreateHandler(); - var innerException = new TimeoutException("Inner timeout"); - var outerException = new InvalidOperationException("Outer", innerException); - - var detectedNestedTransient = false; - handler.TransientExceptionCheck = ex => - { - // Check if inner exception is transient (matches base behavior) - if (ex.InnerException != null) - { - detectedNestedTransient = ex.InnerException is TimeoutException || - ex.InnerException is TaskCanceledException; - } - return false; // Don't retry - }; - - handler.HandleAction = (_, _) => throw outerException; - - var context = CreateMockContext(); - - // Act - try - { - await handler.Consume(context.Object); - } - catch - { - // Expected - } - - // Assert - detectedNestedTransient.Should().BeTrue(); - } - - #endregion - - #region Circuit Breaker Behavior Tests - - [Fact] - public async Task CircuitBreaker_OpensAfterFailureThreshold_WhenNonTransientErrorsExceedThreshold() - { - // Arrange - use default handler with min throughput of 5 - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Non-transient error"); - - // Act - make enough failures to trigger circuit breaker (need >= 5 for min throughput, 50% failure rate) - // With 100% failure rate and min throughput of 5, circuit should open - for (int i = 0; i < 6; i++) - { - try - { - await handler.Consume(CreateMockContext().Object); - } - catch (InvalidOperationException) - { - // Expected - failures needed to open circuit - } - } - - // Assert - handler.CircuitBreakerEvents.Should().Contain(e => e.StartsWith("Open:")); - } - - [Fact] - public async Task CircuitBreaker_ExecutesFallbackWhenOpen() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Non-transient error"); - handler.FallbackAction = (_, _) => Task.CompletedTask; - - // Open the circuit with enough failures - for (int i = 0; i < 6; i++) - { - try { await handler.Consume(CreateMockContext().Object); } - catch { } - } - - // Reset fallback counter after circuit is open - var fallbackCountBefore = handler.FallbackCallCount; - - // Act - next request should hit open circuit and use fallback - await handler.Consume(CreateMockContext().Object); - - // Assert - handler.FallbackCallCount.Should().BeGreaterThan(fallbackCountBefore); - } - - [Fact] - public async Task CircuitBreaker_OnOpenCallback_FiresWhenCircuitOpens() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Test failure"); - - // Act - trigger circuit opening - for (int i = 0; i < 6; i++) - { - try { await handler.Consume(CreateMockContext().Object); } - catch { } - } - - // Assert - var openEvent = handler.CircuitBreakerEvents.FirstOrDefault(e => e.StartsWith("Open:")); - openEvent.Should().NotBeNull(); - openEvent.Should().Contain("Test failure"); - } - - [Fact] - public async Task CircuitBreaker_OnOpenCallback_LogsWarning() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Circuit test"); - - // Act - for (int i = 0; i < 6; i++) - { - try { await handler.Consume(CreateMockContext().Object); } - catch { } - } - - // Assert - verify circuit breaker open warning was logged - _loggerMock.Verify( - x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("Circuit breaker opened")), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - } - - [Fact] - public void CircuitBreaker_DefaultThreshold_IsFive() - { - // Arrange & Act - var handler = CreateHandler(); - - // Assert - verify default by testing the threshold indirectly - // With less than 5 failures, circuit should not open - // This tests that GetCircuitBreakerThreshold returns 5 - handler.CurrentCircuitState.Should().Be(CircuitState.Closed); - } - - [Fact] - public async Task CircuitBreaker_DefaultDuration_IsOneMinute() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Test"); - - // Act - Open the circuit - for (int i = 0; i < 6; i++) - { - try { await handler.Consume(CreateMockContext().Object); } - catch { } - } - - // Assert - verify default duration is 1 minute (60000ms) from the callback - var openEvent = handler.CircuitBreakerEvents.FirstOrDefault(e => e.StartsWith("Open:")); - openEvent.Should().NotBeNull(); - openEvent.Should().Contain("60000ms"); - } - - #endregion - - #region Retry Policy Tests - - [Fact] - public async Task RetryPolicy_RetriesOnTimeoutException() - { - // Arrange - handler uses default 3 retries - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new TimeoutException("Transient timeout"); - - // Act - await Assert.ThrowsAsync(() => handler.Consume(CreateMockContext().Object)); - - // Assert - 1 initial + 3 retries = 4 calls - handler.HandleCallCount.Should().Be(4); - } - - [Fact] - public async Task RetryPolicy_RetriesOnTaskCanceledException() - { - // Arrange - handler uses default 3 retries - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new TaskCanceledException("Transient cancellation"); - - // Act - await Assert.ThrowsAsync(() => handler.Consume(CreateMockContext().Object)); - - // Assert - 1 initial + 3 retries = 4 calls - handler.HandleCallCount.Should().Be(4); - } - - [Fact] - public async Task RetryPolicy_DoesNotRetryOnNonTransientException() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Non-transient"); - // Make fallback also fail so exception propagates - handler.FallbackAction = (_, _) => throw new InvalidOperationException("Fallback also non-transient"); - - // Act - await Assert.ThrowsAsync(() => handler.Consume(CreateMockContext().Object)); - - // Assert - only 1 call, no retries for non-transient - handler.HandleCallCount.Should().Be(1); - } - - [Fact] - public async Task RetryPolicy_LogsRetryAttempt() - { - // Arrange - use custom transient check to fail once then succeed - var handler = CreateHandler(); - var callCount = 0; - handler.HandleAction = (_, _) => - { - callCount++; - if (callCount == 1) - throw new TimeoutException("First attempt timeout"); - return Task.CompletedTask; // Succeed on retry - }; - - // Act - await handler.Consume(CreateMockContext().Object); - - // Assert - verify retry was logged - _loggerMock.Verify( - x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("Retry")), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - } - - [Fact] - public async Task RetryPolicy_SucceedsAfterTransientFailure() - { - // Arrange - var handler = CreateHandler(); - var callCount = 0; - handler.HandleAction = (_, _) => - { - callCount++; - if (callCount < 3) - throw new TimeoutException("Transient failure"); - return Task.CompletedTask; // Succeed on 3rd attempt - }; - - // Act - should succeed after retries - await handler.Consume(CreateMockContext().Object); - - // Assert - callCount.Should().Be(3); // 1 initial + 2 retries before success - } - - #endregion - - #region Timeout Handling Tests - - [Fact] - public async Task Timeout_ThrowsTimeoutRejectedException_WhenOperationExceedsTimeout() - { - // Arrange - use default 30 second timeout but with a blocking operation - var handler = CreateHandler(); - handler.HandleAction = async (_, ct) => - { - // Use a loop that checks cancellation to properly respond to timeout - var endTime = DateTime.UtcNow.AddMinutes(1); - while (DateTime.UtcNow < endTime) - { - ct.ThrowIfCancellationRequested(); - await Task.Delay(100, ct); - } - }; - - // Note: This test would take 30+ seconds with default timeout - // For practical testing, we verify the timeout policy exists and is configured - // by testing with a successful case - } - - [Fact] - public async Task Timeout_CompletesSuccessfully_WhenOperationIsQuick() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = async (_, ct) => - { - await Task.Delay(10, ct); // Very quick operation - }; - - // Act & Assert - should not throw - await handler.Consume(CreateMockContext().Object); - handler.HandleCallCount.Should().Be(1); - } - - [Fact] - public void Timeout_DefaultValue_Is30Seconds() - { - // Arrange - var handler = CreateHandler(); - - // Assert - verify by checking override returns default - // We can't easily test the actual timeout without waiting 30 seconds - // but we verify the configuration is correct - handler.Should().NotBeNull(); - } - - #endregion - - #region Fallback Mechanism Tests - - [Fact] - public async Task Fallback_ExecutesWhenCircuitIsOpen() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Fail"); - var fallbackExecuted = false; - handler.FallbackAction = (_, _) => - { - fallbackExecuted = true; - return Task.CompletedTask; - }; - - // Open the circuit - for (int i = 0; i < 6; i++) - { - try { await handler.Consume(CreateMockContext().Object); } - catch { } - } - - // Reset to check next call - fallbackExecuted = false; - - // Act - this should hit the open circuit - await handler.Consume(CreateMockContext().Object); - - // Assert - fallbackExecuted.Should().BeTrue(); - } - - [Fact] - public async Task Fallback_ExecutesForNonTransientErrors() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Non-transient"); - handler.FallbackAction = (_, _) => Task.CompletedTask; - - // Act - should execute fallback for non-transient error - await handler.Consume(CreateMockContext().Object); - - // Assert - handler.FallbackCallCount.Should().Be(1); - } - - [Fact] - public async Task Fallback_SuccessCompletesNormally_WhenNonTransient() - { - // Arrange - var handler = CreateHandler(); - var fallbackExecuted = false; - - handler.HandleAction = (_, _) => throw new InvalidOperationException("Non-transient"); - handler.FallbackAction = (_, _) => - { - fallbackExecuted = true; - return Task.CompletedTask; - }; - - // Act - should not throw because fallback succeeds - await handler.Consume(CreateMockContext().Object); - - // Assert - fallbackExecuted.Should().BeTrue(); - } - - [Fact] - public async Task Fallback_FailureIsLoggedAndRethrown() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Original error"); - handler.FallbackAction = (_, _) => throw new InvalidOperationException("Fallback error"); - - // Act & Assert - // Note: The base class re-throws the ORIGINAL exception after fallback failure, not the fallback exception - var exception = await Assert.ThrowsAsync( - () => handler.Consume(CreateMockContext().Object)); - - exception.Message.Should().Be("Original error"); - - // Verify fallback failure was logged with the fallback exception - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("Fallback") && - v.ToString()!.Contains("failed")), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - } - - [Fact] - public async Task Fallback_DefaultImplementationLogsWarningAndCompletes() - { - // Arrange - don't set FallbackAction so default is used - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Non-transient"); - // FallbackAction is null - will use default implementation - - // Act - await handler.Consume(CreateMockContext().Object); - - // Assert - verify default fallback warning was logged - _loggerMock.Verify( - x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("No fallback implemented") || - v.ToString()!.Contains("will be skipped")), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - } - - #endregion - - #region Integration Tests - - [Fact] - public async Task SuccessfulExecution_LogsCompletionWithTiming() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => Task.CompletedTask; - - // Act - await handler.Consume(CreateMockContext().Object); - - // Assert - _loggerMock.Verify( - x => x.Log( - LogLevel.Debug, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("Successfully processed") && - v.ToString()!.Contains("ms")), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - [Fact] - public async Task FailedExecution_LogsErrorWithTiming() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => throw new InvalidOperationException("Test error"); - - // Act - try - { - await handler.Consume(CreateMockContext().Object); - } - catch - { - // Expected - fallback also fails by default (just logs warning) - } - - // Assert - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("Failed") && - v.ToString()!.Contains("ms")), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - } - - [Fact] - public async Task Consume_CallsHandleEventAsync_WithCorrectMessage() - { - // Arrange - var handler = CreateHandler(); - var testEvent = new TestEvent { EventId = "test-123", Data = "Custom data" }; - TestEvent? receivedMessage = null; - - handler.HandleAction = (msg, _) => - { - receivedMessage = msg; - return Task.CompletedTask; - }; - - var context = CreateMockContext(testEvent); - - // Act - await handler.Consume(context.Object); - - // Assert - receivedMessage.Should().NotBeNull(); - receivedMessage!.EventId.Should().Be("test-123"); - receivedMessage.Data.Should().Be("Custom data"); - } - - [Fact] - public async Task Consume_PropagatesCancellationToken() - { - // Arrange - var handler = CreateHandler(); - var cts = new CancellationTokenSource(); - CancellationToken? receivedToken = null; - - handler.HandleAction = (_, ct) => - { - receivedToken = ct; - return Task.CompletedTask; - }; - - var context = new Mock>(); - context.Setup(x => x.Message).Returns(new TestEvent()); - context.Setup(x => x.CancellationToken).Returns(cts.Token); - - // Act - await handler.Consume(context.Object); - - // Assert - receivedToken.Should().NotBeNull(); - receivedToken.Should().Be(cts.Token); - } - - [Fact] - public async Task Consume_LogsStartOfProcessing() - { - // Arrange - var handler = CreateHandler(); - handler.HandleAction = (_, _) => Task.CompletedTask; - - // Act - await handler.Consume(CreateMockContext().Object); - - // Assert - _loggerMock.Verify( - x => x.Log( - LogLevel.Debug, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("Processing")), - It.IsAny(), - It.IsAny>()), - Times.AtLeastOnce); - } - - #endregion - } -} diff --git a/Tests/ConduitLLM.Tests/Gateway/Middleware/Assertions/UsageTrackingAssertions.cs b/Tests/ConduitLLM.Tests/Gateway/Middleware/Assertions/UsageTrackingAssertions.cs index 0487cecdf..a4a584ddb 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Middleware/Assertions/UsageTrackingAssertions.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Middleware/Assertions/UsageTrackingAssertions.cs @@ -72,7 +72,7 @@ public static void VerifySpendQueued( decimal expectedCost) { batchService.Verify( - x => x.QueueSpendUpdate(expectedVirtualKeyId, expectedCost), + x => x.QueueSpendUpdateAsync(expectedVirtualKeyId, expectedCost), Times.Once); } @@ -86,7 +86,7 @@ public static void VerifySpendQueuedAny( int expectedVirtualKeyId) { batchService.Verify( - x => x.QueueSpendUpdate(expectedVirtualKeyId, It.IsAny()), + x => x.QueueSpendUpdateAsync(expectedVirtualKeyId, It.IsAny()), Times.Once); } @@ -100,7 +100,7 @@ public static void VerifyNoSpendUpdate( Mock virtualKeyService) { batchService.Verify( - x => x.QueueSpendUpdate(It.IsAny(), It.IsAny()), + x => x.QueueSpendUpdateAsync(It.IsAny(), It.IsAny()), Times.Never); virtualKeyService.Verify( x => x.UpdateSpendAsync(It.IsAny(), It.IsAny()), diff --git a/Tests/ConduitLLM.Tests/Gateway/Middleware/Builders/HttpContextBuilder.cs b/Tests/ConduitLLM.Tests/Gateway/Middleware/Builders/HttpContextBuilder.cs index 312789ae1..bb59541a0 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Middleware/Builders/HttpContextBuilder.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Middleware/Builders/HttpContextBuilder.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http; using ConduitLLM.Core.Models; using ConduitLLM.Gateway.Middleware; +using ConduitLLM.Gateway.UsageTracking; namespace ConduitLLM.Tests.Http.Middleware.Builders { @@ -26,6 +27,7 @@ public class HttpContextBuilder private DateTime? _requestStartTime; private string? _testResponseBody; private readonly Dictionary _additionalItems = new(); + private IUsageContext? _usageContext; /// /// Initializes a new HttpContext builder with default settings. @@ -265,10 +267,13 @@ public HttpContextBuilder WithImageRequest( string size = "1024x1024", int n = 1) { - _additionalItems["ImageRequestModel"] = model; - _additionalItems["ImageRequestQuality"] = quality; - _additionalItems["ImageRequestSize"] = size; - _additionalItems["ImageRequestN"] = n; + _usageContext = new ImageUsageContext + { + Model = model, + Quality = quality, + Size = size, + N = n + }; return this; } @@ -285,12 +290,13 @@ public HttpContextBuilder WithVideoRequest( string? size = null, int n = 1) { - _additionalItems["VideoRequestModel"] = model; - if (duration.HasValue) - _additionalItems["VideoRequestDuration"] = duration.Value; - if (size != null) - _additionalItems["VideoRequestSize"] = size; - _additionalItems["VideoRequestN"] = n; + _usageContext = new VideoUsageContext + { + Model = model, + Duration = duration, + Size = size, + N = n + }; return this; } @@ -358,6 +364,9 @@ public HttpContext Build() foreach (var item in _additionalItems) _context.Items[item.Key] = item.Value; + if (_usageContext != null) + _context.SetUsageContext(_usageContext); + return _context; } diff --git a/Tests/ConduitLLM.Tests/Gateway/Middleware/Fixtures/UsageTrackingMiddlewareTestFixture.cs b/Tests/ConduitLLM.Tests/Gateway/Middleware/Fixtures/UsageTrackingMiddlewareTestFixture.cs index 32a4a16bf..806a233d2 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Middleware/Fixtures/UsageTrackingMiddlewareTestFixture.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Middleware/Fixtures/UsageTrackingMiddlewareTestFixture.cs @@ -113,7 +113,10 @@ public ConduitDbContext GetDbContext() public IToolCostCalculationService GetRealToolCostService() { var loggerMock = new Mock>(); - return new ToolCostCalculationService(GetDbContext(), loggerMock.Object); + var factoryMock = new Mock>(); + factoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(GetDbContext()); + return new ToolCostCalculationService(factoryMock.Object, loggerMock.Object); } /// @@ -262,7 +265,7 @@ private Mock CreateToolCostServiceMock() mock.Setup(x => x.CalculateToolCostsAsync( It.IsAny(), It.IsAny())) - .ReturnsAsync(0m); + .ReturnsAsync(new ToolCostResult { TotalCost = 0m }); mock.Setup(x => x.SerializeToolUsage(It.IsAny())) .Returns(data => System.Text.Json.JsonSerializer.Serialize(data)); return mock; diff --git a/Tests/ConduitLLM.Tests/Gateway/Middleware/UsageTrackingMiddlewareTests.EdgeCases.cs b/Tests/ConduitLLM.Tests/Gateway/Middleware/UsageTrackingMiddlewareTests.EdgeCases.cs index 212206985..bdaf5a841 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Middleware/UsageTrackingMiddlewareTests.EdgeCases.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Middleware/UsageTrackingMiddlewareTests.EdgeCases.cs @@ -67,7 +67,7 @@ await Invoker // Assert - Should use direct update instead of batch Fixture.BatchSpendService.Verify( - x => x.QueueSpendUpdate(It.IsAny(), It.IsAny()), + x => x.QueueSpendUpdateAsync(It.IsAny(), It.IsAny()), Times.Never); UsageTrackingAssertions.VerifyDirectSpendUpdate( Fixture.VirtualKeyService, @@ -116,7 +116,7 @@ await Invoker // Assert UsageTrackingAssertions.VerifyNoCostCalculation(Fixture.CostService); Fixture.BatchSpendService.Verify( - x => x.QueueSpendUpdate(It.IsAny(), It.IsAny()), + x => x.QueueSpendUpdateAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -138,7 +138,7 @@ await Invoker // Assert - No cost calculation or spend update should occur UsageTrackingAssertions.VerifyNoCostCalculation(Fixture.CostService); Fixture.BatchSpendService.Verify( - x => x.QueueSpendUpdate(It.IsAny(), It.IsAny()), + x => x.QueueSpendUpdateAsync(It.IsAny(), It.IsAny()), Times.Never); // Assert - Debug log should indicate billing was skipped due to error response @@ -171,7 +171,7 @@ await Invoker // Assert - No billing should occur for any error status UsageTrackingAssertions.VerifyNoCostCalculation(Fixture.CostService); Fixture.BatchSpendService.Verify( - x => x.QueueSpendUpdate(It.IsAny(), It.IsAny()), + x => x.QueueSpendUpdateAsync(It.IsAny(), It.IsAny()), Times.Never); // Assert - Appropriate debug logging diff --git a/Tests/ConduitLLM.Tests/Gateway/Middleware/VirtualKeyRateLimitMiddlewareTests.cs b/Tests/ConduitLLM.Tests/Gateway/Middleware/VirtualKeyRateLimitMiddlewareTests.cs new file mode 100644 index 000000000..d7f183385 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Gateway/Middleware/VirtualKeyRateLimitMiddlewareTests.cs @@ -0,0 +1,187 @@ +using ConduitLLM.Core.Services; +using ConduitLLM.Gateway.Middleware; + +using FluentAssertions; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +using Moq; + +namespace ConduitLLM.Tests.Http.Middleware +{ + [Trait("Category", "Unit")] + [Trait("Component", "Http")] + public class VirtualKeyRateLimitMiddlewareTests + { + private readonly Mock _mockService = new(); + private bool _nextCalled; + + private VirtualKeyRateLimitMiddleware CreateMiddleware() + { + return new VirtualKeyRateLimitMiddleware( + next: _ => { _nextCalled = true; return Task.CompletedTask; }, + rateLimitService: _mockService.Object, + logger: NullLogger.Instance); + } + + private static DefaultHttpContext NewContext(string path = "/v1/chat/completions") + { + var ctx = new DefaultHttpContext(); + ctx.Request.Path = path; + ctx.Response.Body = new MemoryStream(); + return ctx; + } + + [Theory] + [InlineData("/health")] + [InlineData("/health/ready")] + [InlineData("/metrics")] + [InlineData("/v1/media/public/foo")] + [InlineData("/hubs/images")] + public async Task Skips_excluded_paths_without_calling_service(string path) + { + var ctx = NewContext(path); + await CreateMiddleware().InvokeAsync(ctx); + + _nextCalled.Should().BeTrue(); + _mockService.Verify(s => s.CheckRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Passes_through_when_no_virtual_key_hash_in_items() + { + // Backend-scheme requests (CONDUIT_API_TO_API_BACKEND_AUTH_KEY) authenticate but + // don't stash VirtualKey.KeyHash, so they bypass rate limiting. + var ctx = NewContext(); + await CreateMiddleware().InvokeAsync(ctx); + + _nextCalled.Should().BeTrue(); + _mockService.Verify(s => s.CheckRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Passes_through_when_both_limits_null() + { + // null/null = unlimited per business rule. No Redis round-trip. + var ctx = NewContext(); + ctx.Items["VirtualKey.KeyHash"] = "hash-abc"; + ctx.Items["VirtualKey.RateLimitRpm"] = null; + ctx.Items["VirtualKey.RateLimitRpd"] = null; + + await CreateMiddleware().InvokeAsync(ctx); + + _nextCalled.Should().BeTrue(); + _mockService.Verify(s => s.CheckRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Passes_through_when_both_limits_zero_or_negative() + { + var ctx = NewContext(); + ctx.Items["VirtualKey.KeyHash"] = "hash-abc"; + ctx.Items["VirtualKey.RateLimitRpm"] = 0; + ctx.Items["VirtualKey.RateLimitRpd"] = 0; + + await CreateMiddleware().InvokeAsync(ctx); + + _nextCalled.Should().BeTrue(); + _mockService.Verify(s => s.CheckRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Allows_request_and_sets_headers_when_service_returns_allowed() + { + var ctx = NewContext(); + ctx.Items["VirtualKey.KeyHash"] = "hash-abc"; + ctx.Items["VirtualKey.RateLimitRpm"] = 60; + ctx.Items["VirtualKey.RateLimitRpd"] = null; + + _mockService.Setup(s => s.CheckRateLimitAsync("hash-abc", 60, null)) + .ReturnsAsync(new RateLimitCheckResult + { + IsAllowed = true, + Limit = 60, + RequestsRemaining = 42, + ResetsAt = DateTime.UtcNow.AddSeconds(30), + LimitType = "RPM" + }); + + await CreateMiddleware().InvokeAsync(ctx); + + _nextCalled.Should().BeTrue(); + ctx.Response.Headers["X-RateLimit-Limit"].ToString().Should().Be("60"); + ctx.Response.Headers["X-RateLimit-Remaining"].ToString().Should().Be("42"); + ctx.Response.Headers["X-RateLimit-Scope"].ToString().Should().Be("RPM"); + ctx.Response.Headers.ContainsKey("X-RateLimit-Reset").Should().BeTrue(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [Fact] + public async Task Returns_429_with_Retry_After_when_service_rejects() + { + var ctx = NewContext(); + ctx.Items["VirtualKey.KeyHash"] = "hash-abc"; + ctx.Items["VirtualKey.RateLimitRpm"] = 10; + ctx.Items["VirtualKey.RateLimitRpd"] = null; + + _mockService.Setup(s => s.CheckRateLimitAsync("hash-abc", 10, null)) + .ReturnsAsync(new RateLimitCheckResult + { + IsAllowed = false, + Limit = 10, + RequestsRemaining = 0, + ResetsAt = DateTime.UtcNow.AddSeconds(45), + LimitType = "RPM" + }); + + await CreateMiddleware().InvokeAsync(ctx); + + _nextCalled.Should().BeFalse(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests); + ctx.Response.Headers.ContainsKey("Retry-After").Should().BeTrue(); + int.Parse(ctx.Response.Headers["Retry-After"].ToString()).Should().BeGreaterThan(0); + ctx.Response.Headers["X-RateLimit-Limit"].ToString().Should().Be("10"); + ctx.Response.Headers["X-RateLimit-Scope"].ToString().Should().Be("RPM"); + ctx.Response.ContentType.Should().Contain("application/json"); + } + + [Fact] + public async Task Fails_open_when_service_throws() + { + // If Redis is down, we'd rather let the request through than tank the gateway. + var ctx = NewContext(); + ctx.Items["VirtualKey.KeyHash"] = "hash-abc"; + ctx.Items["VirtualKey.RateLimitRpm"] = 60; + + _mockService.Setup(s => s.CheckRateLimitAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Redis is required for secure distributed rate limiting")); + + await CreateMiddleware().InvokeAsync(ctx); + + _nextCalled.Should().BeTrue(); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [Fact] + public async Task Forwards_both_RPM_and_RPD_when_both_configured() + { + var ctx = NewContext(); + ctx.Items["VirtualKey.KeyHash"] = "hash-abc"; + ctx.Items["VirtualKey.RateLimitRpm"] = 60; + ctx.Items["VirtualKey.RateLimitRpd"] = 10000; + + _mockService.Setup(s => s.CheckRateLimitAsync("hash-abc", 60, 10000)) + .ReturnsAsync(new RateLimitCheckResult { IsAllowed = true, Limit = 60, RequestsRemaining = 50, LimitType = "RPM" }); + + await CreateMiddleware().InvokeAsync(ctx); + + _mockService.Verify(s => s.CheckRateLimitAsync("hash-abc", 60, 10000), Times.Once); + _nextCalled.Should().BeTrue(); + } + } +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Services/ToolCostCalculationServiceTests.cs b/Tests/ConduitLLM.Tests/Gateway/Services/ToolCostCalculationServiceTests.cs index 130a7ea9a..e9af61716 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Services/ToolCostCalculationServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Services/ToolCostCalculationServiceTests.cs @@ -15,16 +15,20 @@ public class ToolCostCalculationServiceTests : IDisposable private readonly ConduitDbContext _context; private readonly Mock> _loggerMock; private readonly ToolCostCalculationService _service; + private readonly DbContextOptions _options; public ToolCostCalculationServiceTests() { - var options = new DbContextOptionsBuilder() + _options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}") .Options; - _context = new ConduitDbContext(options); + _context = new ConduitDbContext(_options); _loggerMock = new Mock>(); - _service = new ToolCostCalculationService(_context, _loggerMock.Object); + var factoryMock = new Mock>(); + factoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new ConduitDbContext(_options)); + _service = new ToolCostCalculationService(factoryMock.Object, _loggerMock.Object); } public void Dispose() @@ -63,7 +67,9 @@ public async Task CalculateToolCostsAsync_WithValidToolConfig_ReturnsCorrectCost var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); // Assert - Assert.Equal(toolCount * costPerUnit, result); + Assert.False(result.Failed); + Assert.False(result.HasUnconfiguredTools); + Assert.Equal(toolCount * costPerUnit, result.TotalCost); } [Fact] @@ -71,7 +77,7 @@ public async Task CalculateToolCostsAsync_WithMultipleTools_ReturnsCombinedCost( { // Arrange var providerType = ProviderType.Groq; - + _context.ProviderTools.AddRange( new ProviderTool { @@ -105,11 +111,11 @@ public async Task CalculateToolCostsAsync_WithMultipleTools_ReturnsCombinedCost( var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); // Assert - Assert.Equal((3 * 0.03m) + (2 * 0.05m), result); // 0.09 + 0.10 = 0.19 + Assert.Equal((3 * 0.03m) + (2 * 0.05m), result.TotalCost); // 0.09 + 0.10 = 0.19 } [Fact] - public async Task CalculateToolCostsAsync_WithMissingToolConfig_ReturnsZero() + public async Task CalculateToolCostsAsync_WithMissingToolConfig_ReturnsZeroAndReportsUnconfigured() { // Arrange var toolUsage = new ToolUsageData @@ -124,15 +130,44 @@ public async Task CalculateToolCostsAsync_WithMissingToolConfig_ReturnsZero() var result = await _service.CalculateToolCostsAsync(toolUsage, ProviderType.Groq); // Assert - Assert.Equal(0m, result); - _loggerMock.Verify( - x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("No cost configuration found")), - It.IsAny(), - It.IsAny>()), - Times.Once); + Assert.Equal(0m, result.TotalCost); + Assert.True(result.HasUnconfiguredTools); + Assert.Contains("nonexistent_tool", result.UnconfiguredToolNames); + } + + [Fact] + public async Task CalculateToolCostsAsync_WithMixedConfiguredAndUnconfigured_ReportsUnconfigured() + { + // Arrange + var providerType = ProviderType.Groq; + + _context.ProviderTools.Add(new ProviderTool + { + Provider = providerType, + ToolName = "code_interpreter", + CostPerUnit = 0.03m, + BillingUnit = "requests", + IsActive = true + }); + await _context.SaveChangesAsync(); + + var toolUsage = new ToolUsageData + { + Tools = new List + { + new ToolUsageItem { ToolName = "code_interpreter", Count = 2 }, + new ToolUsageItem { ToolName = "unknown_tool", Count = 3 } + } + }; + + // Act + var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); + + // Assert โ€” cost only from configured tool, but unconfigured tool is reported + Assert.Equal(2 * 0.03m, result.TotalCost); + Assert.True(result.HasUnconfiguredTools); + Assert.Contains("unknown_tool", result.UnconfiguredToolNames); + Assert.DoesNotContain("code_interpreter", result.UnconfiguredToolNames); } [Fact] @@ -164,7 +199,8 @@ public async Task CalculateToolCostsAsync_WithInactiveToolConfig_ReturnsZero() var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); // Assert - Assert.Equal(0m, result); + Assert.Equal(0m, result.TotalCost); + Assert.True(result.HasUnconfiguredTools); } [Fact] @@ -198,7 +234,7 @@ public async Task CalculateToolCostsAsync_WithHoursBillingUnit_UsesDuration() var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); // Assert - Assert.Equal(durationHours * costPerHour, result); + Assert.Equal(durationHours * costPerHour, result.TotalCost); } [Fact] @@ -232,7 +268,113 @@ public async Task CalculateToolCostsAsync_WithMinutesBillingUnit_UsesDuration() var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); // Assert - Assert.Equal(durationMinutes * costPerMinute, result); + Assert.Equal(durationMinutes * costPerMinute, result.TotalCost); + } + + [Fact] + public async Task CalculateToolCostsAsync_WithDurationSeconds_ConvertsToHours() + { + // Arrange + var providerType = ProviderType.Groq; + var toolName = "code_interpreter"; + var costPerHour = 1.00m; + var durationSeconds = 7200m; // 2 hours + + _context.ProviderTools.Add(new ProviderTool + { + Provider = providerType, + ToolName = toolName, + CostPerUnit = costPerHour, + BillingUnit = "hours", + IsActive = true + }); + await _context.SaveChangesAsync(); + + var toolUsage = new ToolUsageData + { + Tools = new List + { + new ToolUsageItem { ToolName = toolName, Count = 1, DurationSeconds = durationSeconds } + } + }; + + // Act + var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); + + // Assert - 7200 seconds = 2 hours ร— $1.00 = $2.00 + Assert.Equal(2.00m, result.TotalCost); + } + + [Fact] + public async Task CalculateToolCostsAsync_WithDurationSeconds_ConvertsToMinutes() + { + // Arrange + var providerType = ProviderType.Groq; + var toolName = "code_interpreter"; + var costPerMinute = 0.10m; + var durationSeconds = 300m; // 5 minutes + + _context.ProviderTools.Add(new ProviderTool + { + Provider = providerType, + ToolName = toolName, + CostPerUnit = costPerMinute, + BillingUnit = "minutes", + IsActive = true + }); + await _context.SaveChangesAsync(); + + var toolUsage = new ToolUsageData + { + Tools = new List + { + new ToolUsageItem { ToolName = toolName, Count = 1, DurationSeconds = durationSeconds } + } + }; + + // Act + var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); + + // Assert - 300 seconds = 5 minutes ร— $0.10 = $0.50 + Assert.Equal(0.50m, result.TotalCost); + } + + [Fact] + public async Task CalculateToolCostsAsync_DurationSeconds_TakesPriorityOverDuration() + { + // Arrange + var providerType = ProviderType.Groq; + var toolName = "code_interpreter"; + + _context.ProviderTools.Add(new ProviderTool + { + Provider = providerType, + ToolName = toolName, + CostPerUnit = 1.00m, + BillingUnit = "hours", + IsActive = true + }); + await _context.SaveChangesAsync(); + + var toolUsage = new ToolUsageData + { + Tools = new List + { + new ToolUsageItem + { + ToolName = toolName, + Count = 1, + Duration = 99m, // Should be ignored + DurationSeconds = 3600m // 1 hour โ€” should take priority + } + } + }; + + // Act + var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); + + // Assert - DurationSeconds (3600s = 1hr) takes priority over Duration (99) + Assert.Equal(1.00m, result.TotalCost); } [Fact] @@ -242,7 +384,8 @@ public async Task CalculateToolCostsAsync_WithNullToolUsage_ReturnsZero() var result = await _service.CalculateToolCostsAsync(null!, ProviderType.Groq); // Assert - Assert.Equal(0m, result); + Assert.Equal(0m, result.TotalCost); + Assert.False(result.HasUnconfiguredTools); } [Fact] @@ -258,7 +401,7 @@ public async Task CalculateToolCostsAsync_WithEmptyToolsList_ReturnsZero() var result = await _service.CalculateToolCostsAsync(toolUsage, ProviderType.Groq); // Assert - Assert.Equal(0m, result); + Assert.Equal(0m, result.TotalCost); } [Fact] @@ -281,7 +424,7 @@ public void SerializeToolUsage_WithValidData_ReturnsJsonString() Assert.NotNull(result); Assert.Contains("code_interpreter", result); Assert.Contains("browser_search", result); - + // Verify it's valid JSON var deserialized = JsonSerializer.Deserialize(result, new JsonSerializerOptions { @@ -302,7 +445,7 @@ public void SerializeToolUsage_WithNullData_ReturnsEmptyJson() } [Fact] - public async Task CalculateToolCostsAsync_HandlesExceptionGracefully() + public async Task CalculateToolCostsAsync_OnDbFailure_ReturnsFailed() { // Arrange var toolUsage = new ToolUsageData @@ -313,22 +456,19 @@ public async Task CalculateToolCostsAsync_HandlesExceptionGracefully() } }; - // Force an exception by disposing the context - _context.Dispose(); + // Force an exception + var failingFactory = new Mock>(); + failingFactory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Database unavailable")); + + var failingService = new ToolCostCalculationService(failingFactory.Object, _loggerMock.Object); // Act - var result = await _service.CalculateToolCostsAsync(toolUsage, ProviderType.Groq); + var result = await failingService.CalculateToolCostsAsync(toolUsage, ProviderType.Groq); // Assert - Assert.Equal(0m, result); - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Failed to calculate tool costs")), - It.IsAny(), - It.IsAny>()), - Times.Once); + Assert.True(result.Failed); + Assert.Equal(-1m, result.TotalCost); } [Fact] @@ -360,7 +500,8 @@ public async Task CalculateToolCostsAsync_WithNullCostPerUnit_ReturnsZero() var result = await _service.CalculateToolCostsAsync(toolUsage, providerType); // Assert - Assert.Equal(0m, result); + Assert.Equal(0m, result.TotalCost); + Assert.True(result.HasUnconfiguredTools); } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Gateway/Services/UsageAnalyticsNotificationServiceTests.cs b/Tests/ConduitLLM.Tests/Gateway/Services/UsageAnalyticsNotificationServiceTests.cs index b839a54a8..de5a4e5f8 100644 --- a/Tests/ConduitLLM.Tests/Gateway/Services/UsageAnalyticsNotificationServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Gateway/Services/UsageAnalyticsNotificationServiceTests.cs @@ -175,7 +175,7 @@ public async Task SendUsageMetricsAsync_WithException_ShouldLogError() _mockLogger.Verify(x => x.Log( LogLevel.Error, It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains("Failed to send usage metrics")), + It.Is((o, t) => o.ToString()!.Contains("Failed to send")), It.IsAny(), It.IsAny>()), Times.Once); } @@ -474,7 +474,7 @@ public async Task SendGlobalUsageMetricsAsync_WithException_ShouldLogError() _mockLogger.Verify(x => x.Log( LogLevel.Error, It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains("Failed to send global usage metrics")), + It.Is((o, t) => o.ToString()!.Contains("Failed to send")), It.IsAny(), It.IsAny>()), Times.Once); } diff --git a/Tests/ConduitLLM.Tests/Gateway/TestBase.cs b/Tests/ConduitLLM.Tests/Gateway/TestBase.cs deleted file mode 100644 index f0b2ec331..000000000 --- a/Tests/ConduitLLM.Tests/Gateway/TestBase.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Microsoft.Extensions.Logging; - -using Moq; - -using Xunit.Abstractions; - -namespace ConduitLLM.Tests.Http -{ - /// - /// Base class for all HTTP unit tests providing common setup and utilities. - /// - public abstract class TestBase : IDisposable - { - protected readonly ITestOutputHelper Output; - protected readonly ILogger Logger; - private readonly Mock _loggerMock; - protected bool Disposed { get; private set; } - - protected TestBase(ITestOutputHelper output) - { - Output = output ?? throw new ArgumentNullException(nameof(output)); - - // Create a default logger that outputs to xUnit test output - _loggerMock = new Mock(); - Logger = _loggerMock.Object; - } - - /// - /// Creates a logger mock for a specific type - /// - protected Mock> CreateLogger() - { - var logger = new Mock>(); - SetupLogger(logger); - return logger; - } - - /// - /// Sets up logger to write to xUnit test output - /// - private void SetupLogger(Mock> logger) - { - logger.Setup(x => x.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>())) - .Callback((level, eventId, state, exception, formatter) => - { - // Guard against using Output after test completion - if (!Disposed) - { - try - { - var message = state?.ToString() ?? string.Empty; - if (exception != null) - { - message += $" Exception: {exception}"; - } - Output.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] [{level}] {message}"); - } - catch (InvalidOperationException) - { - // Test output is no longer available - ignore - } - } - }); - } - - /// - /// Creates a cancellation token that times out after the specified duration - /// - protected CancellationToken CreateCancellationToken(TimeSpan? timeout = null) - { - var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(30)); - return cts.Token; - } - - /// - /// Logs a message to the test output - /// - protected void Log(string message) - { - Output.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}"); - } - - /// - /// Logs a formatted message to the test output - /// - protected void Log(string format, params object[] args) - { - Output.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] {string.Format(format, args)}"); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!Disposed) - { - if (disposing) - { - // Dispose of any managed resources if needed - } - Disposed = true; - } - } - } -} \ No newline at end of file diff --git a/Tests/ConduitLLM.Tests/Integration/DiscoveryCacheInvalidationIntegrationTests.cs b/Tests/ConduitLLM.Tests/Integration/DiscoveryCacheInvalidationIntegrationTests.cs index 79b1ebeed..bac610f46 100644 --- a/Tests/ConduitLLM.Tests/Integration/DiscoveryCacheInvalidationIntegrationTests.cs +++ b/Tests/ConduitLLM.Tests/Integration/DiscoveryCacheInvalidationIntegrationTests.cs @@ -33,13 +33,13 @@ public async Task InitializeAsync() // Add MassTransit test harness services.AddMassTransitTestHarness(cfg => { - cfg.AddConsumer(); + cfg.AddConsumer(); }); // Add mock services services.AddSingleton(Mock.Of()); services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of>()); + services.AddSingleton(Mock.Of>()); _serviceProvider = services.BuildServiceProvider(); _harness = _serviceProvider.GetRequiredService(); @@ -93,7 +93,7 @@ public async Task Should_Process_ModelMappingChanged_Event_And_Invalidate_Cache( x.Context.Message.MappingId == @event.MappingId)); // Assert - var consumerHarness = _harness.GetConsumerHarness(); + var consumerHarness = _harness.GetConsumerHarness(); Assert.True(await consumerHarness.Consumed.Any()); // Verify the consumer called the cache services diff --git a/Tests/ConduitLLM.Tests/Integration/RefundServiceIntegrationTests.cs b/Tests/ConduitLLM.Tests/Integration/RefundServiceIntegrationTests.cs index 18cc24e97..12a5bd3b3 100644 --- a/Tests/ConduitLLM.Tests/Integration/RefundServiceIntegrationTests.cs +++ b/Tests/ConduitLLM.Tests/Integration/RefundServiceIntegrationTests.cs @@ -31,18 +31,24 @@ public class RefundServiceIntegrationTests : IDisposable private readonly Mock _mockCostCalculationService; private readonly Mock> _mockGroupLogger; private readonly Mock> _mockRefundLogger; + private readonly DbContextOptions _dbOptions; public RefundServiceIntegrationTests() { // Setup in-memory database for integration testing - var options = new DbContextOptionsBuilder() + _dbOptions = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; - _concreteDbContext = new ConduitDbContext(options); + _concreteDbContext = new ConduitDbContext(_dbOptions); _dbContext = _concreteDbContext; + // Create a mock factory that returns contexts with the same database + var mockFactory = new Mock>(); + mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new ConduitDbContext(_dbOptions)); + _mockGroupLogger = new Mock>(); - _groupRepository = new VirtualKeyGroupRepository(_concreteDbContext, _mockGroupLogger.Object); + _groupRepository = new VirtualKeyGroupRepository(mockFactory.Object, _mockGroupLogger.Object); _mockCostCalculationService = new Mock(); _mockRefundLogger = new Mock>(); diff --git a/Tests/ConduitLLM.Tests/Integration/VirtualKeyBalanceTrackingTests.cs b/Tests/ConduitLLM.Tests/Integration/VirtualKeyBalanceTrackingTests.cs index 7424216f0..830866785 100644 --- a/Tests/ConduitLLM.Tests/Integration/VirtualKeyBalanceTrackingTests.cs +++ b/Tests/ConduitLLM.Tests/Integration/VirtualKeyBalanceTrackingTests.cs @@ -23,18 +23,24 @@ public class VirtualKeyBalanceTrackingTests : IDisposable private readonly ConduitDbContext _concreteDbContext; private readonly VirtualKeyGroupRepository _repository; private readonly Mock> _mockLogger; + private readonly DbContextOptions _dbOptions; public VirtualKeyBalanceTrackingTests() { // Setup in-memory database for integration testing - var options = new DbContextOptionsBuilder() + _dbOptions = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; - _concreteDbContext = new ConduitDbContext(options); + _concreteDbContext = new ConduitDbContext(_dbOptions); _dbContext = _concreteDbContext; - + + // Create a mock factory that returns contexts with the same database + var mockFactory = new Mock>(); + mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new ConduitDbContext(_dbOptions)); + _mockLogger = new Mock>(); - _repository = new VirtualKeyGroupRepository(_concreteDbContext, _mockLogger.Object); + _repository = new VirtualKeyGroupRepository(mockFactory.Object, _mockLogger.Object); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Middleware/OpenAIErrorMiddlewareTests.cs b/Tests/ConduitLLM.Tests/Middleware/OpenAIErrorMiddlewareTests.cs index c96ec6fab..7c50c03bf 100644 --- a/Tests/ConduitLLM.Tests/Middleware/OpenAIErrorMiddlewareTests.cs +++ b/Tests/ConduitLLM.Tests/Middleware/OpenAIErrorMiddlewareTests.cs @@ -29,15 +29,15 @@ public OpenAIErrorMiddlewareTests() _mockLogger = new Mock>(); _mockEnvironment = new Mock(); _mockSecurityLogger = new Mock(); - + _mockEnvironment.Setup(x => x.EnvironmentName).Returns(Environments.Production); - + _middleware = new OpenAIErrorMiddleware( _mockNext.Object, _mockLogger.Object, _mockEnvironment.Object, _mockSecurityLogger.Object); - + _httpContext = new DefaultHttpContext(); _httpContext.Response.Body = new MemoryStream(); _httpContext.TraceIdentifier = "test-trace-id"; @@ -50,17 +50,16 @@ public async Task ModelNotFoundException_Returns404WithOpenAIFormat() var modelName = "gpt-5"; _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(new ModelNotFoundException(modelName)); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(404, _httpContext.Response.StatusCode); Assert.Equal("application/json", _httpContext.Response.ContentType); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); Assert.NotNull(errorResponse.Error); Assert.Contains(modelName, errorResponse.Error.Message); @@ -76,16 +75,15 @@ public async Task InvalidRequestException_Returns400WithOpenAIFormat() var exception = new InvalidRequestException("Invalid parameter", "invalid_param", "test_field"); _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(exception); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(400, _httpContext.Response.StatusCode); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); Assert.Equal("Invalid parameter", errorResponse.Error.Message); Assert.Equal("invalid_request_error", errorResponse.Error.Type); @@ -100,20 +98,19 @@ public async Task AuthorizationException_Returns403WithOpenAIFormat() var exception = new AuthorizationException("Access denied"); _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(exception); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(403, _httpContext.Response.StatusCode); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); Assert.Equal("Access denied", errorResponse.Error.Message); Assert.Equal("invalid_request_error", errorResponse.Error.Type); - Assert.Equal("authorization_required", errorResponse.Error.Code); + Assert.Equal("forbidden", errorResponse.Error.Code); } [Fact] @@ -123,16 +120,15 @@ public async Task RequestTimeoutException_Returns408WithOpenAIFormat() var exception = new RequestTimeoutException("Request timed out"); _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(exception); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(408, _httpContext.Response.StatusCode); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); Assert.Equal("Request timed out", errorResponse.Error.Message); Assert.Equal("timeout_error", errorResponse.Error.Type); @@ -146,16 +142,15 @@ public async Task PayloadTooLargeException_Returns413WithOpenAIFormat() var exception = new PayloadTooLargeException("Payload too large", 10000, 5000); _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(exception); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(413, _httpContext.Response.StatusCode); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); Assert.Equal("Payload too large", errorResponse.Error.Message); Assert.Equal("invalid_request_error", errorResponse.Error.Type); @@ -169,17 +164,16 @@ public async Task RateLimitException_Returns429WithOpenAIFormat() var exception = new RateLimitExceededException("Rate limit exceeded", 60); _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(exception); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(429, _httpContext.Response.StatusCode); Assert.Equal("60", _httpContext.Response.Headers["Retry-After"]); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); Assert.Equal("Rate limit exceeded", errorResponse.Error.Message); Assert.Equal("rate_limit_error", errorResponse.Error.Type); @@ -193,38 +187,81 @@ public async Task ServiceUnavailableException_Returns503WithOpenAIFormat() var exception = new ServiceUnavailableException("Service unavailable", "TestService"); _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(exception); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(503, _httpContext.Response.StatusCode); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); Assert.Equal("Service unavailable", errorResponse.Error.Message); Assert.Equal("service_unavailable", errorResponse.Error.Type); Assert.Equal("service_unavailable", errorResponse.Error.Code); } + [Fact] + public async Task LLMCommunicationException_WithStatusCode_ReturnsProviderStatus() + { + // Arrange + var exception = new LLMCommunicationException("Provider error", + System.Net.HttpStatusCode.BadGateway, "Bad gateway"); + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(exception); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(502, _httpContext.Response.StatusCode); + + var errorResponse = GetErrorResponse(_httpContext); + + Assert.NotNull(errorResponse); + Assert.Equal("Provider error", errorResponse.Error.Message); + Assert.Equal("server_error", errorResponse.Error.Type); + Assert.Equal("provider_communication_error", errorResponse.Error.Code); + } + + [Fact] + public async Task LLMCommunicationException_WithoutStatusCode_Returns500() + { + // Arrange + var exception = new LLMCommunicationException("Unknown provider error"); + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(exception); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(500, _httpContext.Response.StatusCode); + + var errorResponse = GetErrorResponse(_httpContext); + + Assert.NotNull(errorResponse); + Assert.Equal("Unknown provider error", errorResponse.Error.Message); + Assert.Equal("server_error", errorResponse.Error.Type); + Assert.Equal("provider_communication_error", errorResponse.Error.Code); + } + [Fact] public async Task UnhandledException_Returns500WithGenericMessage() { // Arrange _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(new Exception("Internal error details")); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(500, _httpContext.Response.StatusCode); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); // In production, should not expose internal details Assert.Equal("An unexpected error occurred", errorResponse.Error.Message); @@ -239,26 +276,83 @@ public async Task UnhandledException_InDevelopment_ShowsDetails() _mockEnvironment.Setup(x => x.EnvironmentName).Returns(Environments.Development); _mockNext.Setup(x => x(It.IsAny())) .ThrowsAsync(new Exception("Detailed error message")); - + // Act await _middleware.InvokeAsync(_httpContext); - + // Assert Assert.Equal(500, _httpContext.Response.StatusCode); - - var responseBody = GetResponseBody(_httpContext); - var errorResponse = JsonSerializer.Deserialize(responseBody); - + + var errorResponse = GetErrorResponse(_httpContext); + Assert.NotNull(errorResponse); // In development, should show actual error message Assert.Equal("Detailed error message", errorResponse.Error.Message); } - private static string GetResponseBody(HttpContext context) + [Fact] + public async Task ArgumentNullException_Returns400WithSafeMessage() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new ArgumentNullException("apiKey")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(400, _httpContext.Response.StatusCode); + + var errorResponse = GetErrorResponse(_httpContext); + + Assert.NotNull(errorResponse); + Assert.Equal("Required parameter is missing", errorResponse.Error.Message); + Assert.Equal("invalid_request_error", errorResponse.Error.Type); + Assert.Equal("missing_parameter", errorResponse.Error.Code); + Assert.Equal("apiKey", errorResponse.Error.Param); + } + + [Fact] + public async Task ArgumentNullException_InDevelopment_ShowsDetails() + { + // Arrange + _mockEnvironment.Setup(x => x.EnvironmentName).Returns(Environments.Development); + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new ArgumentNullException("apiKey")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal(400, _httpContext.Response.StatusCode); + + var errorResponse = GetErrorResponse(_httpContext); + + Assert.NotNull(errorResponse); + // In development, should show actual exception message + Assert.Contains("apiKey", errorResponse.Error.Message); + } + + [Fact] + public async Task XRequestIdHeader_IsSet() + { + // Arrange + _mockNext.Setup(x => x(It.IsAny())) + .ThrowsAsync(new Exception("test")); + + // Act + await _middleware.InvokeAsync(_httpContext); + + // Assert + Assert.Equal("test-trace-id", _httpContext.Response.Headers["X-Request-Id"]); + } + + private static OpenAIErrorResponse GetErrorResponse(HttpContext context) { context.Response.Body.Position = 0; using var reader = new StreamReader(context.Response.Body); - return reader.ReadToEnd(); + var body = reader.ReadToEnd(); + return JsonSerializer.Deserialize(body)!; } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Providers/DatabaseAwareLLMClientFactoryTests.cs b/Tests/ConduitLLM.Tests/Providers/DatabaseAwareLLMClientFactoryTests.cs index 04728f2fe..a81cc3565 100644 --- a/Tests/ConduitLLM.Tests/Providers/DatabaseAwareLLMClientFactoryTests.cs +++ b/Tests/ConduitLLM.Tests/Providers/DatabaseAwareLLMClientFactoryTests.cs @@ -24,12 +24,12 @@ public DatabaseAwareLLMClientFactoryTests() _mockLoggerFactory = new Mock(); _mockHttpClientFactory = new Mock(); _mockLogger = new Mock>(); - + _mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())) .Returns(Mock.Of()); - + var mockServiceProvider = new Mock(); - + _factory = new DatabaseAwareLLMClientFactory( _mockCredentialService.Object, _mockMappingService.Object, @@ -40,24 +40,24 @@ public DatabaseAwareLLMClientFactoryTests() } [Fact] - public void GetClient_WithNonExistentModel_ThrowsModelNotFoundException() + public async Task GetClientAsync_WithNonExistentModel_ThrowsModelNotFoundException() { // Arrange var modelName = "non-existent-model"; _mockMappingService.Setup(x => x.GetMappingByModelAliasAsync(modelName)) .ReturnsAsync((ModelProviderMapping?)null); - + // Act & Assert - var exception = Assert.Throws( - () => _factory.GetClient(modelName) + var exception = await Assert.ThrowsAsync( + async () => await _factory.GetClientAsync(modelName) ); - + Assert.Equal($"Model '{modelName}' not found. Please check your model configuration.", exception.Message); Assert.Equal(modelName, exception.ModelName); } [Fact] - public void GetClient_WithDisabledProvider_ThrowsServiceUnavailableException() + public async Task GetClientAsync_WithDisabledProvider_ThrowsServiceUnavailableException() { // Arrange var modelName = "test-model"; @@ -69,7 +69,7 @@ public void GetClient_WithDisabledProvider_ThrowsServiceUnavailableException() ProviderId = 1, ProviderModelId = "gpt-4" }; - + var provider = new Provider { Id = 1, @@ -77,24 +77,24 @@ public void GetClient_WithDisabledProvider_ThrowsServiceUnavailableException() ProviderType = ProviderType.OpenAI, IsEnabled = false // Disabled provider }; - + _mockMappingService.Setup(x => x.GetMappingByModelAliasAsync(modelName)) .ReturnsAsync(mapping); - + _mockCredentialService.Setup(x => x.GetProviderByIdAsync(1)) .ReturnsAsync(provider); - + // Act & Assert - var exception = Assert.Throws( - () => _factory.GetClient(modelName) + var exception = await Assert.ThrowsAsync( + async () => await _factory.GetClientAsync(modelName) ); - + Assert.Equal($"Provider 'TestProvider' is currently disabled.", exception.Message); Assert.Equal("TestProvider", exception.ServiceName); } [Fact] - public void GetClient_WithNoApiKey_ThrowsConfigurationException() + public async Task GetClientAsync_WithNoApiKey_ThrowsConfigurationException() { // Arrange var modelName = "test-model"; @@ -106,7 +106,7 @@ public void GetClient_WithNoApiKey_ThrowsConfigurationException() ProviderId = 1, ProviderModelId = "gpt-4" }; - + var provider = new Provider { Id = 1, @@ -114,59 +114,59 @@ public void GetClient_WithNoApiKey_ThrowsConfigurationException() ProviderType = ProviderType.OpenAI, IsEnabled = true }; - + _mockMappingService.Setup(x => x.GetMappingByModelAliasAsync(modelName)) .ReturnsAsync(mapping); - + _mockCredentialService.Setup(x => x.GetProviderByIdAsync(1)) .ReturnsAsync(provider); - + // Return empty list of key credentials _mockCredentialService.Setup(x => x.GetKeyCredentialsByProviderIdAsync(1)) .ReturnsAsync(new List()); - + // Act & Assert - var exception = Assert.Throws( - () => _factory.GetClient(modelName) + var exception = await Assert.ThrowsAsync( + async () => await _factory.GetClientAsync(modelName) ); - + Assert.Contains("No API key configured", exception.Message); } [Fact] - public void GetClientByProviderId_WithNonExistentProvider_ThrowsInvalidRequestException() + public async Task GetClientByProviderIdAsync_WithNonExistentProvider_ThrowsInvalidRequestException() { // Arrange var providerId = 999; _mockCredentialService.Setup(x => x.GetProviderByIdAsync(providerId)) .ReturnsAsync((Provider?)null); - + // Act & Assert - var exception = Assert.Throws( - () => _factory.GetClientByProviderId(providerId) + var exception = await Assert.ThrowsAsync( + async () => await _factory.GetClientByProviderIdAsync(providerId) ); - + Assert.Equal($"Provider with ID '{providerId}' not found.", exception.Message); Assert.Equal("provider_not_found", exception.ErrorCode); Assert.Equal("providerId", exception.Param); } [Fact] - public void GetClientByProviderType_WithNoProvider_ThrowsInvalidRequestException() + public async Task GetClientByProviderTypeAsync_WithNoProvider_ThrowsInvalidRequestException() { // Arrange var providerType = ProviderType.OpenAI; _mockCredentialService.Setup(x => x.GetAllProvidersAsync()) .ReturnsAsync(new List()); - + // Act & Assert - var exception = Assert.Throws( - () => _factory.GetClientByProviderType(providerType) + var exception = await Assert.ThrowsAsync( + async () => await _factory.GetClientByProviderTypeAsync(providerType) ); - + Assert.Equal($"No provider configured for type '{providerType}'.", exception.Message); Assert.Equal("provider_type_not_found", exception.ErrorCode); Assert.Equal("providerType", exception.Param); } } -} \ No newline at end of file +} diff --git a/Tests/ConduitLLM.Tests/Providers/Helpers/AsyncJobPollerTests.cs b/Tests/ConduitLLM.Tests/Providers/Helpers/AsyncJobPollerTests.cs new file mode 100644 index 000000000..a81a6210b --- /dev/null +++ b/Tests/ConduitLLM.Tests/Providers/Helpers/AsyncJobPollerTests.cs @@ -0,0 +1,524 @@ +using ConduitLLM.Core.Exceptions; +using ConduitLLM.Core.Metrics; +using ConduitLLM.Providers.Helpers; + +using FluentAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using Xunit; + +namespace ConduitLLM.Tests.Providers.Helpers; + +public class AsyncJobPollerTests +{ + private sealed record Status(string Name); + + private static PollingOptions Fast( + BackoffStrategy backoff = BackoffStrategy.Fixed, + int? maxTransient = null, + int timeoutSeconds = 30) => new( + InitialDelay: TimeSpan.FromMilliseconds(1), + MaxDelay: TimeSpan.FromMilliseconds(10), + Timeout: TimeSpan.FromSeconds(timeoutSeconds), + Backoff: backoff, + MaxConsecutiveTransientErrors: maxTransient, + BackoffMultiplier: 2.0, + JitterMilliseconds: 0, + HeartbeatLogEveryNAttempts: 100); + + private static Func NoDelay() => + (_, _) => Task.CompletedTask; + + [Fact] + public async Task PollAsync_SucceedsFirstTry_ReturnsExtractedResult() + { + var calls = 0; + + var result = await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status("done")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: s => s.Name + "!", + extractFailure: _ => new InvalidOperationException("should not be called"), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + result.Should().Be("done!"); + calls.Should().Be(1); + } + + [Fact] + public async Task PollAsync_SucceedsAfterSeveralPolls_EventuallyReturns() + { + var calls = 0; + + var result = await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status(calls >= 4 ? "done" : "processing")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: _ => 42, + extractFailure: _ => new InvalidOperationException(), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + result.Should().Be(42); + calls.Should().Be(4); + } + + [Fact] + public async Task PollAsync_Failed_ThrowsExceptionFromExtractFailure() + { + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => Task.FromResult(new Status("failed")), + classify: s => s.Name == "failed" ? JobState.Failed : JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: s => new ModelNotFoundException("m", $"provider said {s.Name}"), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + (await act.Should().ThrowAsync()) + .WithMessage("*provider said failed*"); + } + + [Fact] + public async Task PollAsync_Timeout_ThrowsRequestTimeoutAndCallsOnAbort() + { + var abortCalled = false; + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => Task.FromResult(new Status("working")), + classify: _ => JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(timeoutSeconds: 0), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + onAbort: () => + { + abortCalled = true; + return Task.CompletedTask; + }, + delayFunc: NoDelay()); + + await act.Should().ThrowAsync(); + abortCalled.Should().BeTrue(); + } + + [Fact] + public async Task PollAsync_CancellationTriggersOnAbortAndThrowsOperationCanceled() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var abortCalled = false; + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => Task.FromResult(new Status("working")), + classify: _ => JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: cts.Token, + onAbort: () => + { + abortCalled = true; + return Task.CompletedTask; + }, + delayFunc: NoDelay()); + + await act.Should().ThrowAsync(); + abortCalled.Should().BeTrue(); + } + + [Fact] + public async Task PollAsync_FetchThrows_FailFastMode_PropagatesAsLLMCommunicationException() + { + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => throw new HttpRequestException("network down"), + classify: _ => JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(maxTransient: null), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + (await act.Should().ThrowAsync()) + .WithMessage("*network down*"); + } + + [Fact] + public async Task PollAsync_FetchThrows_TolerantMode_RetriesUntilThreshold() + { + var calls = 0; + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + throw new HttpRequestException($"fail {calls}"); + }, + classify: _ => JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(maxTransient: 3), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + await act.Should().ThrowAsync(); + calls.Should().Be(3); + } + + [Fact] + public async Task PollAsync_FetchThrows_TolerantMode_ResetsCounterOnSuccess() + { + var calls = 0; + + var result = await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + if (calls == 1 || calls == 2) + { + throw new HttpRequestException("transient"); + } + return Task.FromResult(new Status(calls >= 5 ? "done" : "processing")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: _ => "ok", + extractFailure: _ => new InvalidOperationException(), + options: Fast(maxTransient: 3), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + result.Should().Be("ok"); + calls.Should().Be(5); + } + + [Fact] + public async Task PollAsync_RateLimited_ContinuesPolling() + { + var calls = 0; + + var result = await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status(calls switch + { + 1 => "throttled", + 2 => "throttled", + _ => "done", + })); + }, + classify: s => s.Name switch + { + "done" => JobState.Succeeded, + "throttled" => JobState.RateLimited, + _ => JobState.InProgress, + }, + extractSuccess: _ => true, + extractFailure: _ => new InvalidOperationException(), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + result.Should().BeTrue(); + calls.Should().Be(3); + } + + [Fact] + public async Task PollAsync_TransientError_CountsTowardThreshold() + { + var calls = 0; + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status("server-error")); + }, + classify: _ => JobState.TransientError, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(maxTransient: 3), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + await act.Should().ThrowAsync(); + calls.Should().Be(3); + } + + [Fact] + public async Task PollAsync_TransientError_FailFastMode_ThrowsImmediately() + { + var calls = 0; + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status("server-error")); + }, + classify: _ => JobState.TransientError, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(maxTransient: null), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + await act.Should().ThrowAsync(); + calls.Should().Be(1); + } + + [Fact] + public async Task PollAsync_ExponentialBackoff_GrowsAndCapsAtMaxDelay() + { + var delaysRequested = new List(); + var calls = 0; + + var options = new PollingOptions( + InitialDelay: TimeSpan.FromMilliseconds(10), + MaxDelay: TimeSpan.FromMilliseconds(50), + Timeout: TimeSpan.FromSeconds(30), + Backoff: BackoffStrategy.ExponentialWithJitter, + BackoffMultiplier: 2.0, + JitterMilliseconds: 0, + HeartbeatLogEveryNAttempts: 100); + + await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status(calls >= 6 ? "done" : "processing")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: options, + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: (d, _) => { delaysRequested.Add(d); return Task.CompletedTask; }); + + // Called 6 times, 5 delays between them. + delaysRequested.Should().HaveCount(5); + delaysRequested[0].Should().Be(TimeSpan.FromMilliseconds(10)); // initial + delaysRequested[1].Should().Be(TimeSpan.FromMilliseconds(20)); // 10*2 + delaysRequested[2].Should().Be(TimeSpan.FromMilliseconds(40)); // 20*2 + delaysRequested[3].Should().Be(TimeSpan.FromMilliseconds(50)); // capped + delaysRequested[4].Should().Be(TimeSpan.FromMilliseconds(50)); // capped + } + + [Fact] + public async Task PollAsync_FixedBackoff_AlwaysUsesInitialDelay() + { + var delaysRequested = new List(); + var calls = 0; + + await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status(calls >= 4 ? "done" : "processing")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: (d, _) => { delaysRequested.Add(d); return Task.CompletedTask; }); + + delaysRequested.Should().OnlyContain(d => d == TimeSpan.FromMilliseconds(1)); + } + + [Fact] + public async Task PollAsync_OnProgress_InvokedOnEveryAttempt() + { + var calls = 0; + var progressReports = new List(); + + await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status(calls >= 3 ? "done" : "processing")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + onProgress: (p, _) => { progressReports.Add(p); return Task.CompletedTask; }, + delayFunc: NoDelay()); + + progressReports.Should().HaveCount(3); + progressReports[0].State.Should().Be(JobState.InProgress); + progressReports[2].State.Should().Be(JobState.Succeeded); + } + + [Fact] + public async Task PollAsync_OnProgressThrows_SwallowedAndPollingContinues() + { + var calls = 0; + + var result = await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status(calls >= 2 ? "done" : "processing")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: _ => 99, + extractFailure: _ => new InvalidOperationException(), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + onProgress: (_, _) => throw new Exception("progress blew up"), + delayFunc: NoDelay()); + + result.Should().Be(99); + } + + [Fact] + public async Task PollAsync_OnAbortThrows_TimeoutExceptionStillSurfaced() + { + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => Task.FromResult(new Status("working")), + classify: _ => JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(timeoutSeconds: 0), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + onAbort: () => throw new Exception("abort blew up"), + delayFunc: NoDelay()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PollAsync_FetchThrowsConduitException_PropagatesWithoutCountingAsTransient() + { + var calls = 0; + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + throw new RateLimitExceededException("slow down"); + }, + classify: _ => JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: Fast(maxTransient: 5), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay()); + + await act.Should().ThrowAsync(); + calls.Should().Be(1); + } + + [Fact] + public async Task PollAsync_WithInstrumentation_PropagatesResultUnchanged() + { + // The scope is caller-owned, so we just verify that PollAsync accepts one, + // advances it through attempts, and propagates results unchanged. + using var scope = ProviderInstrumentation.BeginPolling( + operation: "TestOp", + providerName: "TestProvider", + providerType: "TestType", + model: "test-model"); + + var calls = 0; + var result = await AsyncJobPoller.PollAsync( + fetchStatus: _ => + { + calls++; + return Task.FromResult(new Status(calls >= 3 ? "done" : "processing")); + }, + classify: s => s.Name == "done" ? JobState.Succeeded : JobState.InProgress, + extractSuccess: _ => "ok", + extractFailure: _ => new InvalidOperationException(), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay(), + instrumentation: scope); + + result.Should().Be("ok"); + calls.Should().Be(3); + } + + [Fact] + public async Task PollAsync_WithInstrumentation_TimeoutRecordsTimeoutOutcome() + { + using var scope = ProviderInstrumentation.BeginPolling( + operation: "TestOp", + providerName: "TestProvider", + providerType: "TestType", + model: "test-model"); + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => Task.FromResult(new Status("processing")), + classify: _ => JobState.InProgress, + extractSuccess: _ => 0, + extractFailure: _ => new InvalidOperationException(), + options: new PollingOptions( + InitialDelay: TimeSpan.FromMilliseconds(1), + MaxDelay: TimeSpan.FromMilliseconds(10), + Timeout: TimeSpan.FromMilliseconds(1), + Backoff: BackoffStrategy.Fixed), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: (_, _) => Task.Delay(5), + instrumentation: scope); + + await act.Should().ThrowAsync(); + // Scope disposal happens via `using`; this test primarily confirms no interaction + // breaks the timeout contract โ€” the metric recording is fire-and-forget. + } + + [Fact] + public async Task PollAsync_WithInstrumentation_ClassifierFailurePropagates() + { + using var scope = ProviderInstrumentation.BeginPolling( + operation: "TestOp", + providerName: "TestProvider", + providerType: "TestType", + model: "test-model"); + + var act = async () => await AsyncJobPoller.PollAsync( + fetchStatus: _ => Task.FromResult(new Status("failed")), + classify: _ => JobState.Failed, + extractSuccess: _ => 0, + extractFailure: s => new ModelNotFoundException("m", $"provider said {s.Name}"), + options: Fast(), + logger: NullLogger.Instance, + cancellationToken: CancellationToken.None, + delayFunc: NoDelay(), + instrumentation: scope); + + await act.Should().ThrowAsync(); + } +} diff --git a/Tests/ConduitLLM.Tests/Providers/Helpers/ContentHelperTests.cs b/Tests/ConduitLLM.Tests/Providers/Helpers/ContentHelperTests.cs new file mode 100644 index 000000000..4af57a2fd --- /dev/null +++ b/Tests/ConduitLLM.Tests/Providers/Helpers/ContentHelperTests.cs @@ -0,0 +1,211 @@ +using System.Text.Json; +using ConduitLLM.Providers.Helpers; +using FluentAssertions; +using Xunit; + +namespace ConduitLLM.Tests.Providers.Helpers; + +public class ContentHelperTests +{ + [Fact] + public void ShouldPreserveAsArray_WithCacheControl_ReturnsTrue() + { + // Arrange โ€” content array with cache_control on a text block + var json = """ + [ + { "type": "text", "text": "System prompt", "cache_control": { "type": "ephemeral" } }, + { "type": "text", "text": "Hello" } + ] + """; + var content = JsonSerializer.Deserialize(json); + + // Act + var result = ContentHelper.ShouldPreserveAsArray(content); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldPreserveAsArray_PlainText_ReturnsFalse() + { + // Arrange โ€” plain string content + var result = ContentHelper.ShouldPreserveAsArray("Hello world"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldPreserveAsArray_NullContent_ReturnsFalse() + { + ContentHelper.ShouldPreserveAsArray(null).Should().BeFalse(); + } + + [Fact] + public void ShouldPreserveAsArray_TextOnlyArray_ReturnsFalse() + { + // Arrange โ€” content array with only type/text, no cache_control + var json = """ + [ + { "type": "text", "text": "Hello" }, + { "type": "text", "text": "World" } + ] + """; + var content = JsonSerializer.Deserialize(json); + + // Act + var result = ContentHelper.ShouldPreserveAsArray(content); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldPreserveAsArray_ImageOnly_ReturnsFalse() + { + // Arrange โ€” content array with image but no cache_control + var json = """ + [ + { "type": "text", "text": "Describe this" }, + { "type": "image_url", "image_url": { "url": "https://example.com/img.png" } } + ] + """; + var content = JsonSerializer.Deserialize(json); + + // Act + var result = ContentHelper.ShouldPreserveAsArray(content); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldPreserveAsArray_ImageWithCacheControl_ReturnsTrue() + { + // Arrange โ€” content array with both image and cache_control + var json = """ + [ + { "type": "text", "text": "System prompt", "cache_control": { "type": "ephemeral" } }, + { "type": "image_url", "image_url": { "url": "https://example.com/img.png" } } + ] + """; + var content = JsonSerializer.Deserialize(json); + + // Act + var result = ContentHelper.ShouldPreserveAsArray(content); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldPreserveAsArray_JsonString_ReturnsFalse() + { + // Arrange โ€” JsonElement of kind String + var content = JsonSerializer.Deserialize("\"Hello\""); + + // Act + var result = ContentHelper.ShouldPreserveAsArray(content); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void IsTextOnly_WithCacheControl_StillReturnsTrue() + { + // Verify that IsTextOnly still returns true for text+cache_control (no images) + // This is important because ShouldPreserveAsArray takes priority in the mapping + var json = """ + [ + { "type": "text", "text": "Hello", "cache_control": { "type": "ephemeral" } } + ] + """; + var content = JsonSerializer.Deserialize(json); + + ContentHelper.IsTextOnly(content).Should().BeTrue(); + ContentHelper.ShouldPreserveAsArray(content).Should().BeTrue(); + } + + [Fact] + public void ShouldPreserveAsArray_ListOfDictsWithCacheControl_ReturnsTrue() + { + // Arrange โ€” content produced by PromptCacheInjectionService (string โ†’ List) + var content = new List + { + new Dictionary + { + ["type"] = "text", + ["text"] = "System prompt", + ["cache_control"] = new Dictionary { ["type"] = "ephemeral" } + } + }; + + // Act + var result = ContentHelper.ShouldPreserveAsArray(content); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldPreserveAsArray_ListOfDictsWithoutCacheControl_ReturnsFalse() + { + // Arrange โ€” List without cache_control + var content = new List + { + new Dictionary + { + ["type"] = "text", + ["text"] = "Hello" + } + }; + + // Act + var result = ContentHelper.ShouldPreserveAsArray(content); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void InjectionThenMapping_RoundTrip_PreservesCacheControl() + { + // Arrange โ€” simulate what PromptCacheInjectionService does to string content + var message = new ConduitLLM.Core.Models.Message + { + Role = "system", + Content = "You are a helpful assistant." + }; + + var config = new ConduitLLM.Core.Models.PromptCachingConfig + { + AutoInjectEnabled = true, + InjectionPoints = new List + { + new() { Role = "system", Index = 0 } + } + }; + + var request = new ConduitLLM.Core.Models.ChatCompletionRequest + { + Model = "test", + Messages = new List { message } + }; + + // Act โ€” inject cache control + ConduitLLM.Core.Services.PromptCacheInjectionService.InjectCacheControl(request, config); + + // Assert โ€” ShouldPreserveAsArray must return true for the modified content + ContentHelper.ShouldPreserveAsArray(message.Content).Should().BeTrue(); + + // Verify the content is a list with cache_control + var contentList = message.Content.Should().BeAssignableTo>().Subject.ToList(); + contentList.Should().HaveCount(1); + var dict = contentList[0].Should().BeAssignableTo>().Subject; + dict.Should().ContainKey("cache_control"); + dict["type"].Should().Be("text"); + dict["text"].Should().Be("You are a helpful assistant."); + } +} diff --git a/Tests/ConduitLLM.Tests/Providers/OpenAICompatibleMappingTests.cs b/Tests/ConduitLLM.Tests/Providers/OpenAICompatibleMappingTests.cs new file mode 100644 index 000000000..537203551 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Providers/OpenAICompatibleMappingTests.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using ConduitLLM.Core.Models; +using ConduitLLM.Providers.OpenAICompatible; +using FluentAssertions; +using Xunit; + +namespace ConduitLLM.Tests.Providers; + +/// +/// Tests for cached token extraction from provider-specific usage formats. +/// Tests the internal static ExtractCachedTokensFromExtensionData method. +/// +public class OpenAICompatibleMappingTests +{ + [Fact] + public void ExtractCachedTokens_OpenAIFormat_MapsCachedInputTokens() + { + // Arrange โ€” OpenAI returns prompt_tokens_details.cached_tokens + var usageJson = """ + { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "prompt_tokens_details": { + "cached_tokens": 80 + } + } + """; + var usage = JsonSerializer.Deserialize(usageJson); + + // Act + OpenAICompatibleClient.ExtractCachedTokensFromExtensionData(usage); + + // Assert + usage!.CachedInputTokens.Should().Be(80); + usage.CachedWriteTokens.Should().BeNull(); + } + + [Fact] + public void ExtractCachedTokens_AnthropicFormat_MapsBothCachedFields() + { + // Arrange โ€” Anthropic returns cache_read_input_tokens and cache_creation_input_tokens + var usageJson = """ + { + "prompt_tokens": 200, + "completion_tokens": 50, + "total_tokens": 250, + "cache_read_input_tokens": 150, + "cache_creation_input_tokens": 30 + } + """; + var usage = JsonSerializer.Deserialize(usageJson); + + // Act + OpenAICompatibleClient.ExtractCachedTokensFromExtensionData(usage); + + // Assert + usage!.CachedInputTokens.Should().Be(150); + usage.CachedWriteTokens.Should().Be(30); + } + + [Fact] + public void ExtractCachedTokens_DeepseekFormat_MapsCachedInputTokens() + { + // Arrange โ€” Deepseek returns prompt_cache_hit_tokens + var usageJson = """ + { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "prompt_cache_hit_tokens": 60 + } + """; + var usage = JsonSerializer.Deserialize(usageJson); + + // Act + OpenAICompatibleClient.ExtractCachedTokensFromExtensionData(usage); + + // Assert + usage!.CachedInputTokens.Should().Be(60); + } + + [Fact] + public void ExtractCachedTokens_NullUsage_DoesNotThrow() + { + // Act & Assert โ€” should not throw + OpenAICompatibleClient.ExtractCachedTokensFromExtensionData(null); + } + + [Fact] + public void ExtractCachedTokens_NoExtensionData_DoesNotModifyUsage() + { + // Arrange โ€” standard usage without any provider-specific fields + var usage = new Usage + { + PromptTokens = 100, + CompletionTokens = 50, + TotalTokens = 150 + }; + + // Act + OpenAICompatibleClient.ExtractCachedTokensFromExtensionData(usage); + + // Assert + usage.CachedInputTokens.Should().BeNull(); + usage.CachedWriteTokens.Should().BeNull(); + } + + [Fact] + public void ExtractCachedTokens_ExistingCachedTokens_DoesNotOverwrite() + { + // Arrange โ€” Usage already has cached_input_tokens set (e.g., from direct JSON deserialization) + // plus provider-specific extension data that would also map + var usageJson = """ + { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "cached_input_tokens": 42, + "cache_read_input_tokens": 99 + } + """; + var usage = JsonSerializer.Deserialize(usageJson); + + // Act + OpenAICompatibleClient.ExtractCachedTokensFromExtensionData(usage); + + // Assert โ€” the named property (42) should be preserved, not overwritten by extension data (99) + usage!.CachedInputTokens.Should().Be(42); + } + + [Fact] + public void ExtractCachedTokens_StreamingChunkUsage_MapsCorrectly() + { + // Arrange โ€” simulate a streaming final chunk with usage data (as providers send it) + var chunkJson = """ + { + "id": "chatcmpl-123", + "object": "chat.completion.chunk", + "created": 1234567890, + "model": "gpt-4", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "prompt_tokens_details": { + "cached_tokens": 80 + } + } + } + """; + var chunk = JsonSerializer.Deserialize(chunkJson); + + // Act โ€” this is what the streaming code path does + OpenAICompatibleClient.ExtractCachedTokensFromExtensionData(chunk!.Usage); + + // Assert + chunk.Usage!.CachedInputTokens.Should().Be(80); + } +} diff --git a/Tests/ConduitLLM.Tests/Security/Authorization/HealthKeyAuthorizationHandlerTests.cs b/Tests/ConduitLLM.Tests/Security/Authorization/HealthKeyAuthorizationHandlerTests.cs new file mode 100644 index 000000000..85455995a --- /dev/null +++ b/Tests/ConduitLLM.Tests/Security/Authorization/HealthKeyAuthorizationHandlerTests.cs @@ -0,0 +1,293 @@ +using System.Net; +using System.Security.Claims; + +using ConduitLLM.Security.Authorization; + +using FluentAssertions; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +using Moq; + +using Xunit; +using Xunit.Abstractions; + +namespace ConduitLLM.Tests.Security.Authorization; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +[Trait("Component", "Security")] +public class HealthKeyAuthorizationHandlerTests : TestBase +{ + private const string TestHealthKey = "test-health-monitoring-key-12345"; + private readonly Mock> _loggerMock; + + public HealthKeyAuthorizationHandlerTests(ITestOutputHelper output) : base(output) + { + _loggerMock = CreateLogger(); + } + + private HealthKeyAuthorizationHandler CreateHandler(string? healthKey = TestHealthKey) + { + // Set the environment variable for the test + if (healthKey != null) + { + Environment.SetEnvironmentVariable(HealthKeyAuthorizationHandler.HealthKeyEnvVar, healthKey); + } + else + { + Environment.SetEnvironmentVariable(HealthKeyAuthorizationHandler.HealthKeyEnvVar, null); + } + + return new HealthKeyAuthorizationHandler(_loggerMock.Object); + } + + private static HttpContext CreateHttpContext(IPAddress? remoteIpAddress, string? healthKeyHeader = null) + { + var context = new DefaultHttpContext(); + + if (remoteIpAddress != null) + { + context.Connection.RemoteIpAddress = remoteIpAddress; + } + + if (healthKeyHeader != null) + { + context.Request.Headers[HealthKeyAuthorizationHandler.HealthKeyHeaderName] = healthKeyHeader; + } + + return context; + } + + private static AuthorizationHandlerContext CreateAuthorizationContext(HttpContext httpContext) + { + var requirements = new[] { new HealthKeyRequirement() }; + var user = new ClaimsPrincipal(new ClaimsIdentity()); + return new AuthorizationHandlerContext(requirements, user, httpContext); + } + + [Fact] + public async Task HandleRequirementAsync_PrivateNetworkRequest_10x_Succeeds() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Parse("10.0.0.1")); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("10.x.x.x is a private network"); + } + + [Fact] + public async Task HandleRequirementAsync_PrivateNetworkRequest_172_Succeeds() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Parse("172.16.0.1")); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("172.16.x.x is a private network"); + } + + [Fact] + public async Task HandleRequirementAsync_PrivateNetworkRequest_192_Succeeds() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Parse("192.168.1.1")); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("192.168.x.x is a private network"); + } + + [Fact] + public async Task HandleRequirementAsync_PrivateNetworkRequest_Loopback_Succeeds() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Loopback); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("127.0.0.1 is loopback/private"); + } + + [Fact] + public async Task HandleRequirementAsync_PrivateNetworkRequest_IPv6Loopback_Succeeds() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.IPv6Loopback); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("::1 is IPv6 loopback/private"); + } + + [Fact] + public async Task HandleRequirementAsync_ExternalWithValidKey_Succeeds() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Parse("203.0.113.1"), TestHealthKey); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("Valid health key was provided"); + } + + [Fact] + public async Task HandleRequirementAsync_ExternalWithInvalidKey_Fails() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Parse("203.0.113.1"), "wrong-key"); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeFalse("Invalid health key was provided"); + } + + [Fact] + public async Task HandleRequirementAsync_ExternalWithNoKey_Fails() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Parse("203.0.113.1")); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeFalse("No health key was provided"); + } + + [Fact] + public async Task HandleRequirementAsync_ExternalWithEmptyKey_Fails() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(IPAddress.Parse("203.0.113.1"), ""); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeFalse("Empty health key is not valid"); + } + + [Fact] + public async Task HandleRequirementAsync_KeyNotConfigured_PrivateNetworkStillSucceeds() + { + // Arrange + var handler = CreateHandler(healthKey: null); + var httpContext = CreateHttpContext(IPAddress.Parse("10.0.0.1")); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("Private network should still work without key configured"); + } + + [Fact] + public async Task HandleRequirementAsync_KeyNotConfigured_ExternalFails() + { + // Arrange + var handler = CreateHandler(healthKey: null); + var httpContext = CreateHttpContext(IPAddress.Parse("203.0.113.1"), "some-key"); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeFalse("External requests should fail when key is not configured"); + } + + [Fact] + public async Task HandleRequirementAsync_NoHttpContext_DoesNotSucceed() + { + // Arrange + var handler = CreateHandler(); + var requirements = new[] { new HealthKeyRequirement() }; + var user = new ClaimsPrincipal(new ClaimsIdentity()); + var authContext = new AuthorizationHandlerContext(requirements, user, resource: null); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeFalse("No HttpContext means we can't evaluate the requirement"); + } + + [Fact] + public async Task HandleRequirementAsync_NullRemoteIpAddress_WithValidKey_Succeeds() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(remoteIpAddress: null, TestHealthKey); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeTrue("Valid key should work even without remote IP"); + } + + [Fact] + public async Task HandleRequirementAsync_NullRemoteIpAddress_WithoutKey_Fails() + { + // Arrange + var handler = CreateHandler(); + var httpContext = CreateHttpContext(remoteIpAddress: null); + var authContext = CreateAuthorizationContext(httpContext); + + // Act + await handler.HandleAsync(authContext); + + // Assert + authContext.HasSucceeded.Should().BeFalse("No IP and no key should fail"); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + // Clean up environment variable after tests + Environment.SetEnvironmentVariable(HealthKeyAuthorizationHandler.HealthKeyEnvVar, null); + } + base.Dispose(disposing); + } +} diff --git a/Tests/ConduitLLM.Tests/Security/Middleware/HealthEndpointAuthorizationMiddlewareTests.cs b/Tests/ConduitLLM.Tests/Security/Middleware/HealthEndpointAuthorizationMiddlewareTests.cs new file mode 100644 index 000000000..571567b91 --- /dev/null +++ b/Tests/ConduitLLM.Tests/Security/Middleware/HealthEndpointAuthorizationMiddlewareTests.cs @@ -0,0 +1,374 @@ +using System.Net; + +using ConduitLLM.Security.Authorization; +using ConduitLLM.Security.Middleware; + +using FluentAssertions; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +using Moq; + +using Xunit; +using Xunit.Abstractions; + +namespace ConduitLLM.Tests.Security.Middleware; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +[Trait("Component", "Security")] +public class HealthEndpointAuthorizationMiddlewareTests : TestBase +{ + private const string TestHealthKey = "test-health-monitoring-key-12345"; + private readonly Mock> _loggerMock; + + public HealthEndpointAuthorizationMiddlewareTests(ITestOutputHelper output) : base(output) + { + _loggerMock = CreateLogger(); + } + + private HealthEndpointAuthorizationMiddleware CreateMiddleware( + RequestDelegate next, + string? healthKey = TestHealthKey) + { + // Set the environment variable for the test + if (healthKey != null) + { + Environment.SetEnvironmentVariable(HealthKeyAuthorizationHandler.HealthKeyEnvVar, healthKey); + } + else + { + Environment.SetEnvironmentVariable(HealthKeyAuthorizationHandler.HealthKeyEnvVar, null); + } + + return new HealthEndpointAuthorizationMiddleware(next, _loggerMock.Object); + } + + private static DefaultHttpContext CreateHttpContext( + string path, + IPAddress? remoteIpAddress = null, + string? healthKeyHeader = null) + { + var context = new DefaultHttpContext(); + context.Request.Path = path; + + if (remoteIpAddress != null) + { + context.Connection.RemoteIpAddress = remoteIpAddress; + } + + if (healthKeyHeader != null) + { + context.Request.Headers[HealthKeyAuthorizationHandler.HealthKeyHeaderName] = healthKeyHeader; + } + + return context; + } + + [Fact] + public async Task InvokeAsync_HealthEndpoint_PrivateNetwork_PassesThrough() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health", IPAddress.Parse("10.0.0.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeTrue("Private network requests should pass through"); + context.Response.StatusCode.Should().NotBe(404); + } + + [Fact] + public async Task InvokeAsync_HealthEndpoint_ExternalWithValidKey_PassesThrough() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health", IPAddress.Parse("203.0.113.1"), TestHealthKey); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeTrue("Valid health key should pass through"); + context.Response.StatusCode.Should().NotBe(404); + } + + [Fact] + public async Task InvokeAsync_HealthEndpoint_ExternalNoKey_Returns404() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse("Unauthorized external requests should not pass through"); + context.Response.StatusCode.Should().Be(404, "Should return 404 to hide endpoint existence"); + } + + [Fact] + public async Task InvokeAsync_HealthEndpoint_ExternalInvalidKey_Returns404() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health", IPAddress.Parse("203.0.113.1"), "wrong-key"); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse("Invalid key should not pass through"); + context.Response.StatusCode.Should().Be(404, "Should return 404 to hide endpoint existence"); + } + + [Fact] + public async Task InvokeAsync_NonHealthEndpoint_PassesThroughRegardless() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/api/chat/completions", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeTrue("Non-health endpoints should pass through regardless of IP/key"); + } + + [Fact] + public async Task InvokeAsync_HealthLiveEndpoint_SameRulesApply() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health/live", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse("/health/live should be protected"); + context.Response.StatusCode.Should().Be(404); + } + + [Fact] + public async Task InvokeAsync_HealthReadyEndpoint_SameRulesApply() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health/ready", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse("/health/ready should be protected"); + context.Response.StatusCode.Should().Be(404); + } + + [Fact] + public async Task InvokeAsync_ApiHealthServicesEndpoint_SameRulesApply() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/api/health/services", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse("/api/health/* should be protected"); + context.Response.StatusCode.Should().Be(404); + } + + [Fact] + public async Task InvokeAsync_HealthSignalREndpoint_SameRulesApply() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health/signalr", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse("/health/signalr should be protected"); + context.Response.StatusCode.Should().Be(404); + } + + [Theory] + [InlineData("10.0.0.1")] + [InlineData("172.16.0.1")] + [InlineData("172.31.255.255")] + [InlineData("192.168.0.1")] + [InlineData("127.0.0.1")] + public async Task InvokeAsync_VariousPrivateNetworkIPs_AllPassThrough(string ipAddress) + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health", IPAddress.Parse(ipAddress)); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeTrue($"IP {ipAddress} should be recognized as private network"); + } + + [Theory] + [InlineData("8.8.8.8")] + [InlineData("203.0.113.1")] + [InlineData("1.1.1.1")] + [InlineData("172.32.0.1")] // Just outside 172.16-31 range + public async Task InvokeAsync_VariousPublicIPs_AllReturn404WithoutKey(string ipAddress) + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/health", IPAddress.Parse(ipAddress)); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse($"IP {ipAddress} should be recognized as public"); + context.Response.StatusCode.Should().Be(404); + } + + [Fact] + public async Task InvokeAsync_CaseSensitivePath_StillMatches() + { + // Arrange + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/Health", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert + nextCalled.Should().BeFalse("/Health (uppercase) should still be protected"); + context.Response.StatusCode.Should().Be(404); + } + + [Fact] + public async Task InvokeAsync_HealthyEndpoint_NotProtected() + { + // Arrange - /healthy is NOT /health, so it should pass through + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var middleware = CreateMiddleware(next); + var context = CreateHttpContext("/healthy", IPAddress.Parse("203.0.113.1")); + + // Act + await middleware.InvokeAsync(context); + + // Assert - This depends on the implementation. If /healthy starts with /health, it might be protected. + // Based on the middleware using StartsWith, /healthy WILL be protected as it starts with "/health" + // This test documents the current behavior + nextCalled.Should().BeFalse("/healthy starts with /health so it is protected"); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + // Clean up environment variable after tests + Environment.SetEnvironmentVariable(HealthKeyAuthorizationHandler.HealthKeyEnvVar, null); + } + base.Dispose(disposing); + } +} diff --git a/Tests/ConduitLLM.Tests/Services/Orchestrators/ImageGenerationOrchestratorTests.cs b/Tests/ConduitLLM.Tests/Services/Orchestrators/ImageGenerationOrchestratorTests.cs index acd815a8b..70c08d6d9 100644 --- a/Tests/ConduitLLM.Tests/Services/Orchestrators/ImageGenerationOrchestratorTests.cs +++ b/Tests/ConduitLLM.Tests/Services/Orchestrators/ImageGenerationOrchestratorTests.cs @@ -44,6 +44,7 @@ protected override ImageGenerationOrchestrator CreateOrchestrator() HttpClientFactoryMock.Object, ParameterValidatorMock.Object, Metrics, + ErrorTrackingServiceMock.Object, LoggerMock.Object as ILogger ?? new Mock>().Object); } @@ -97,8 +98,8 @@ protected override void SetupSuccessfulGeneration(ConduitLLM.Core.Models.ImageGe It.IsAny())) .ReturnsAsync(response); - ClientFactoryMock.Setup(x => x.GetClient(It.IsAny())) - .Returns(mockClient.Object); + ClientFactoryMock.Setup(x => x.GetClientAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(mockClient.Object); StorageServiceMock.Setup(x => x.StoreAsync( It.IsAny(), @@ -115,8 +116,8 @@ protected override void SetupSuccessfulGeneration(ConduitLLM.Core.Models.ImageGe protected override void SetupFailedGeneration(Exception exception) { // Setup to simulate failure during orchestration - ClientFactoryMock.Setup(x => x.GetClient(It.IsAny())) - .Throws(exception); + ClientFactoryMock.Setup(x => x.GetClientAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(exception); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Services/Orchestrators/MediaGenerationOrchestratorTestBase.cs b/Tests/ConduitLLM.Tests/Services/Orchestrators/MediaGenerationOrchestratorTestBase.cs index 1980f093b..63ac43909 100644 --- a/Tests/ConduitLLM.Tests/Services/Orchestrators/MediaGenerationOrchestratorTestBase.cs +++ b/Tests/ConduitLLM.Tests/Services/Orchestrators/MediaGenerationOrchestratorTestBase.cs @@ -44,6 +44,7 @@ public abstract class MediaGenerationOrchestratorTestBase HttpClientFactoryMock; protected readonly Mock ParameterValidatorMock; protected readonly MediaGenerationMetrics Metrics; + protected readonly Mock ErrorTrackingServiceMock; protected readonly Mock LoggerMock; // System under test @@ -77,6 +78,7 @@ protected MediaGenerationOrchestratorTestBase() TaskRegistryMock = new Mock(); WebhookServiceMock = new Mock(); HttpClientFactoryMock = new Mock(); + ErrorTrackingServiceMock = new Mock(); LoggerMock = new Mock(); // MinimalParameterValidator requires a logger in its constructor diff --git a/Tests/ConduitLLM.Tests/Services/Orchestrators/VideoGenerationOrchestratorTests.cs b/Tests/ConduitLLM.Tests/Services/Orchestrators/VideoGenerationOrchestratorTests.cs index 7e27359d0..52add3458 100644 --- a/Tests/ConduitLLM.Tests/Services/Orchestrators/VideoGenerationOrchestratorTests.cs +++ b/Tests/ConduitLLM.Tests/Services/Orchestrators/VideoGenerationOrchestratorTests.cs @@ -94,6 +94,7 @@ protected override VideoGenerationOrchestrator CreateOrchestrator() HttpClientFactoryMock.Object, ParameterValidatorMock.Object, Metrics, + ErrorTrackingServiceMock.Object, LoggerMock.Object as ILogger ?? new Mock>().Object); } @@ -137,8 +138,8 @@ protected override void SetupSuccessfulGeneration(VideoGenerationResponse respon // Use test client that has CreateVideoAsync method for reflection var testClient = new TestVideoClient(response); - ClientFactoryMock.Setup(x => x.GetClient(It.IsAny())) - .Returns(testClient); + ClientFactoryMock.Setup(x => x.GetClientAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(testClient); StorageServiceMock.Setup(x => x.StoreAsync( It.IsAny(), @@ -155,8 +156,8 @@ protected override void SetupSuccessfulGeneration(VideoGenerationResponse respon protected override void SetupFailedGeneration(Exception exception) { // Setup to simulate failure during orchestration - ClientFactoryMock.Setup(x => x.GetClient(It.IsAny())) - .Throws(exception); + ClientFactoryMock.Setup(x => x.GetClientAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(exception); } [Fact] diff --git a/Tests/ConduitLLM.Tests/Services/ProviderErrorTrackingServiceTests.cs b/Tests/ConduitLLM.Tests/Services/ProviderErrorTrackingServiceTests.cs index 5439ca60b..910ea6b32 100644 --- a/Tests/ConduitLLM.Tests/Services/ProviderErrorTrackingServiceTests.cs +++ b/Tests/ConduitLLM.Tests/Services/ProviderErrorTrackingServiceTests.cs @@ -113,8 +113,10 @@ public async Task TrackErrorAsync_ThresholdExceeded_DisablesKey() .ReturnsAsync(testKey); _keyRepoMock.Setup(x => x.UpdateAsync(It.IsAny())) .ReturnsAsync(true); - _keyRepoMock.Setup(x => x.GetByProviderIdAsync(error.ProviderId)) - .ReturnsAsync(new List { testKey }); + var keyList = new List { testKey }; + _keyRepoMock.Setup(x => x.GetByProviderIdPaginatedAsync( + error.ProviderId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((keyList, keyList.Count)); // Act await _service.TrackErrorAsync(error); @@ -195,7 +197,22 @@ public async Task ClearErrorsForKeyAsync_CallsErrorStore() await _service.ClearErrorsForKeyAsync(keyId); // Assert - _errorStoreMock.Verify(x => x.ClearErrorsForKeyAsync(keyId), + _errorStoreMock.Verify(x => x.ClearErrorsForKeyAsync(keyId, null), + Times.Once); + } + + [Fact] + public async Task ClearErrorsForKeyAsync_WithProviderId_PassesProviderIdToStore() + { + // Arrange + var keyId = 123; + var providerId = 456; + + // Act + await _service.ClearErrorsForKeyAsync(keyId, providerId); + + // Assert + _errorStoreMock.Verify(x => x.ClearErrorsForKeyAsync(keyId, providerId), Times.Once); } @@ -233,7 +250,7 @@ public async Task GetRecentErrorsAsync_ReturnsFilteredResults() } }; - _errorStoreMock.Setup(x => x.GetRecentErrorsAsync(100)) + _errorStoreMock.Setup(x => x.GetRecentErrorsAsync(It.IsAny())) .ReturnsAsync(feedEntries); // Act @@ -381,8 +398,10 @@ public async Task DisableKeyAsync_SecondaryKey_DisablesKeyOnly() _keyRepoMock.Setup(x => x.GetByIdAsync(keyId)) .ReturnsAsync(secondaryKey); - _keyRepoMock.Setup(x => x.GetByProviderIdAsync(providerId)) - .ReturnsAsync(new List { secondaryKey, otherKey }); + var providerKeys = new List { secondaryKey, otherKey }; + _keyRepoMock.Setup(x => x.GetByProviderIdPaginatedAsync( + providerId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((providerKeys, providerKeys.Count)); // Act await _service.DisableKeyAsync(keyId, reason); @@ -429,8 +448,10 @@ public async Task DisableKeyAsync_AllKeysDisabled_DisablesProvider() _keyRepoMock.Setup(x => x.GetByIdAsync(keyId)) .ReturnsAsync(key1); - _keyRepoMock.Setup(x => x.GetByProviderIdAsync(providerId)) - .ReturnsAsync(new List { key1, key2 }); + var providerKeys = new List { key1, key2 }; + _keyRepoMock.Setup(x => x.GetByProviderIdPaginatedAsync( + providerId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((providerKeys, providerKeys.Count)); _providerRepoMock.Setup(x => x.GetByIdAsync(providerId, It.IsAny())) .ReturnsAsync(provider); @@ -545,15 +566,16 @@ public async Task GetErrorStatisticsAsync_CalculatesCorrectStats() _errorStoreMock.Setup(x => x.GetErrorStatisticsAsync(window)) .ReturnsAsync(statsData); - var allKeys = new[] + var allKeys = new List { new ProviderKeyCredential { Id = 1, IsEnabled = true }, new ProviderKeyCredential { Id = 2, IsEnabled = false }, new ProviderKeyCredential { Id = 3, IsEnabled = false } }; - - _keyRepoMock.Setup(x => x.GetAllAsync()) - .ReturnsAsync(allKeys.ToList()); + + _keyRepoMock.Setup(x => x.GetPaginatedAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((allKeys, allKeys.Count)); // Act var stats = await _service.GetErrorStatisticsAsync(window); diff --git a/Tests/ConduitLLM.Tests/Utilities/ParameterConverterTests.cs b/Tests/ConduitLLM.Tests/Utilities/ParameterConverterTests.cs index 993e0dfd9..08d84c845 100644 --- a/Tests/ConduitLLM.Tests/Utilities/ParameterConverterTests.cs +++ b/Tests/ConduitLLM.Tests/Utilities/ParameterConverterTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using ConduitLLM.Providers.Utilities; namespace ConduitLLM.Tests.Utilities @@ -142,7 +143,7 @@ public void ConvertStopSequences_WithSingleItem_ReturnsString() var result = ParameterConverter.ConvertStopSequences(input); // Assert - Assert.IsType(result); + result.Should().BeOfType(); Assert.Equal("stop1", result); } @@ -156,8 +157,7 @@ public void ConvertStopSequences_WithMultipleItems_ReturnsStringList() var result = ParameterConverter.ConvertStopSequences(input); // Assert - Assert.IsType>(result); - var resultList = (List)result; + var resultList = result.Should().BeOfType>().Subject; Assert.Equal(3, resultList.Count); Assert.Equal("stop1", resultList[0]); Assert.Equal("stop2", resultList[1]); diff --git a/Tests/README.md b/Tests/README.md index 0a3b69854..60daa778a 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -300,6 +300,6 @@ When adding new tests: ## Support For questions, issues, or contributions: -- Open an issue on the [GitHub repository](https://github.com/knnlabs/Conduit/issues) +- Open an issue on the [GitHub repository](https://github.com/nickna/Conduit/issues) - Review existing documentation in the `docs/` directory - Check project-specific README files for detailed information diff --git a/WebAdmin/Dockerfile b/WebAdmin/Dockerfile index 265bbb09c..e6cf7359e 100755 --- a/WebAdmin/Dockerfile +++ b/WebAdmin/Dockerfile @@ -1,5 +1,5 @@ # Optimized multi-stage Dockerfile for WebAdmin -# Features: Multi-stage build, Alpine base, non-root user, health checks +# Features: Multi-stage build, standalone output, Alpine base, non-root user, health checks FROM node:22-alpine AS builder WORKDIR /app @@ -7,7 +7,7 @@ WORKDIR /app # IMPORTANT: This is a monorepo where packages depend on each other via file: references # The WebAdmin package.json contains: # "@knn_labs/conduit-admin-client": "file:../SDKs/Node/Admin" -# "@knn_labs/conduit-core-client": "file:../SDKs/Node/Core" +# "@knn_labs/conduit-gateway-client": "file:../SDKs/Node/Gateway" # Therefore, we MUST copy the entire monorepo structure before running npm install # Otherwise npm will timeout trying to fetch these packages from the registry COPY . . @@ -28,21 +28,21 @@ WORKDIR /app/SDKs/Node/Admin RUN npm install --no-audit --no-fund --verbose || (cat /root/.npm/_logs/*.log 2>/dev/null && exit 1) RUN npm run build -# Build Core SDK next (depends on Common package) -WORKDIR /app/SDKs/Node/Core +# Build Gateway SDK next (depends on Common package) +WORKDIR /app/SDKs/Node/Gateway RUN npm install --no-audit --no-fund --verbose || (cat /root/.npm/_logs/*.log 2>/dev/null && exit 1) RUN npm run build -# Build WebAdmin last (depends on Admin and Core SDKs via file: references) -# The WebAdmin's npm install will symlink to the local Admin and Core packages +# Build WebAdmin last (depends on Admin and Gateway SDKs via file: references) WORKDIR /app/WebAdmin -# Create public directory if it doesn't exist (Next.js 15 doesn't require it) +# Create public directory if it doesn't exist (Next.js 16 doesn't require it) RUN mkdir -p public # npm install here will resolve file: dependencies to the already-built SDKs above RUN npm install --no-audit --no-fund --verbose || (cat /root/.npm/_logs/*.log 2>/dev/null && exit 1) RUN npm run build -# Production stage - smaller final image +# Production stage - minimal image using standalone output +# next.config.js has output: 'standalone' which bundles only needed dependencies FROM node:22-alpine AS runner WORKDIR /app @@ -56,18 +56,13 @@ ENV PORT=3000 ENV HOSTNAME="0.0.0.0" ENV NEXT_TELEMETRY_DISABLED=1 -# Copy only what's needed from builder -COPY --from=builder --chown=nextjs:nodejs /app/WebAdmin/package*.json ./ -COPY --from=builder --chown=nextjs:nodejs /app/WebAdmin/.next ./.next -COPY --from=builder --chown=nextjs:nodejs /app/WebAdmin/node_modules ./node_modules -COPY --from=builder --chown=nextjs:nodejs /app/WebAdmin/public ./public - -# IMPORTANT: We must also copy the SDK packages because WebAdmin's node_modules contains -# symlinks to these local packages (due to file: references in package.json) -# Without these, the runtime will fail to resolve the packages -COPY --from=builder --chown=nextjs:nodejs /app/SDKs/Node/Common /app/SDKs/Node/Common -COPY --from=builder --chown=nextjs:nodejs /app/SDKs/Node/Admin /app/SDKs/Node/Admin -COPY --from=builder --chown=nextjs:nodejs /app/SDKs/Node/Core /app/SDKs/Node/Core +# Copy standalone output โ€” self-contained server with all dependencies bundled +# No need for node_modules or SDK symlinks; standalone includes everything +# outputFileTracingRoot is set to repo root, so standalone preserves monorepo structure: +# .next/standalone/WebAdmin/server.js, .next/standalone/SDKs/Node/... +COPY --from=builder --chown=nextjs:nodejs /app/WebAdmin/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/WebAdmin/.next/static ./WebAdmin/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/WebAdmin/public ./WebAdmin/public # Switch to non-root user USER nextjs @@ -78,5 +73,6 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -q -O /dev/null http://localhost:3000/api/health || exit 1 -# Start the application -CMD ["npm", "start"] \ No newline at end of file +# Start using the standalone server directly (faster startup than npm start) +# server.js is nested under WebAdmin/ due to outputFileTracingRoot preserving monorepo structure +CMD ["node", "WebAdmin/server.js"] diff --git a/WebAdmin/docs/README.md b/WebAdmin/docs/README.md index 7d0e569b9..d2237750a 100755 --- a/WebAdmin/docs/README.md +++ b/WebAdmin/docs/README.md @@ -85,5 +85,5 @@ When adding new documentation: ## Additional Resources - [WebAdmin README](../README.md) - Main project documentation -- [Conduit Documentation](https://github.com/knnlabs/Conduit/docs) - Platform documentation +- [Conduit Documentation](https://github.com/nickna/Conduit/docs) - Platform documentation - [SDK Documentation](https://www.npmjs.com/package/@knn_labs/conduit-gateway-client) - SDK reference \ No newline at end of file diff --git a/WebAdmin/docs/TROUBLESHOOTING.md b/WebAdmin/docs/TROUBLESHOOTING.md index 1845bdb37..e7d3e38f9 100755 --- a/WebAdmin/docs/TROUBLESHOOTING.md +++ b/WebAdmin/docs/TROUBLESHOOTING.md @@ -300,7 +300,7 @@ if (process.env.NODE_ENV === 'development') { If you're still experiencing issues: -1. Check the [GitHub Issues](https://github.com/knnlabs/Conduit/issues) +1. Check the [GitHub Issues](https://github.com/nickna/Conduit/issues) 2. Review the [Documentation](./README.md) 3. Enable debug logging and collect logs 4. Create a minimal reproduction example diff --git a/WebAdmin/next.config.js b/WebAdmin/next.config.js index 8554fa96f..18b42cc75 100755 --- a/WebAdmin/next.config.js +++ b/WebAdmin/next.config.js @@ -1,14 +1,28 @@ +const path = require('path'); + /** @type {import('next').NextConfig} */ const nextConfig = { + // Standalone output for optimized Docker deployments + // Produces a self-contained build that doesn't need node_modules at runtime + output: 'standalone', + // Monorepo: trace files from repo root so SDK dependencies are included in standalone output + // This causes standalone to preserve directory structure (WebAdmin/server.js, SDKs/Node/...) + outputFileTracingRoot: path.resolve(__dirname, '..'), experimental: { optimizePackageImports: ['@mantine/core', '@mantine/hooks', '@mantine/charts'], + // Turbopack resolution for monorepo file: dependencies + turbopack: { + resolveAlias: { + '@knn_labs/conduit-admin-client': path.resolve(__dirname, '../SDKs/Node/Admin/dist'), + '@knn_labs/conduit-gateway-client': path.resolve(__dirname, '../SDKs/Node/Gateway/dist'), + '@knn_labs/conduit-common': path.resolve(__dirname, '../SDKs/Node/Common/dist'), + }, + }, }, transpilePackages: [ '@knn_labs/conduit-admin-client', '@knn_labs/conduit-gateway-client' ], - // Enable source maps for better debugging - productionBrowserSourceMaps: true, // Enable React strict mode for additional checks reactStrictMode: true, // Image configuration to allow loading from API server @@ -32,59 +46,6 @@ const nextConfig = { }, ], }, - // Enhanced webpack configuration for hot reload - webpack: (config, { dev, isServer }) => { - // Fix for CommonJS modules - if (!isServer) { - config.externals = config.externals || []; - config.externals.push({ - 'utf-8-validate': 'commonjs utf-8-validate', - 'bufferutil': 'commonjs bufferutil', - }); - } - - // Better source maps for debugging - if (dev && !isServer) { - // Use default devtool to avoid performance issues - // config.devtool = 'eval-source-map'; - } - - // Enable React DevTools - const webpack = require('webpack'); - config.plugins.push( - new webpack.DefinePlugin({ - '__REACT_DEVTOOLS_GLOBAL_HOOK__': '({ isDisabled: false })', - }) - ); - - // Disable optimization for better debugging but enable code splitting - if (dev) { - config.optimization = { - ...config.optimization, - minimize: false, - minimizer: [], - // Disable custom splitChunks to fix exports error - splitChunks: false, - runtimeChunk: false, - }; - } else { - // Also disable optimization in production for debugging - config.optimization = { - ...config.optimization, - minimize: false, - minimizer: [], - }; - - // Enhanced hot reload configuration - config.watchOptions = { - poll: 1000, - aggregateTimeout: 300, - ignored: ['**/node_modules', '**/.next'], - }; - } - - return config; - }, } module.exports = nextConfig diff --git a/WebAdmin/package-lock.json b/WebAdmin/package-lock.json index 3adcb94a1..ce88e9f9f 100644 --- a/WebAdmin/package-lock.json +++ b/WebAdmin/package-lock.json @@ -9,75 +9,72 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@clerk/nextjs": "^6.36.3", + "@clerk/nextjs": "^6.37.1", "@hello-pangea/dnd": "^18.0.1", "@knn_labs/conduit-admin-client": "file:../SDKs/Node/Admin", "@knn_labs/conduit-common": "file:../SDKs/Node/Common", "@knn_labs/conduit-gateway-client": "file:../SDKs/Node/Gateway", - "@mantine/carousel": "^8.1.2", - "@mantine/charts": "^8.1.2", - "@mantine/code-highlight": "^8.1.2", - "@mantine/core": "^8.1.2", - "@mantine/dates": "^8.1.2", - "@mantine/form": "^8.1.2", - "@mantine/hooks": "^8.1.2", - "@mantine/modals": "^8.1.2", - "@mantine/notifications": "^8.1.2", - "@mantine/spotlight": "^8.1.2", - "@microsoft/signalr": "^9.0.6", - "@microsoft/signalr-protocol-msgpack": "^9.0.6", - "@tabler/icons-react": "^3.34.1", - "@tanstack/react-query": "^5.0.0", - "@tanstack/react-virtual": "^3.13.12", - "@types/node": "^24.0.15", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@types/video.js": "^7.3.58", - "@typescript-eslint/eslint-plugin": "^8.35.0", - "@typescript-eslint/parser": "^8.35.0", - "axios": "^1.10.0", + "@mantine/carousel": "^8.3.14", + "@mantine/charts": "^8.3.14", + "@mantine/code-highlight": "^8.3.14", + "@mantine/core": "^8.3.14", + "@mantine/dates": "^8.3.14", + "@mantine/form": "^8.3.14", + "@mantine/hooks": "^8.3.14", + "@mantine/modals": "^8.3.14", + "@mantine/notifications": "^8.3.14", + "@mantine/spotlight": "^8.3.14", + "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr-protocol-msgpack": "^10.0.0", + "@tabler/icons-react": "^3.36.1", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-virtual": "^3.13.18", + "axios": "^1.13.4", "date-fns": "^4.1.0", - "eslint": "^9.30.0", - "next": "16.0.10", - "react": "^19.2.3", - "react-dom": "^19.2.3", + "next": "^16.1.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.1", + "react-syntax-highlighter": "^16.1.0", "remark-gfm": "^4.0.1", - "typescript": "^5.8.3", - "uuid": "^11.1.0", - "video.js": "^8.23.3", - "zod": "^4.0.5", - "zustand": "^5.0.6" + "typescript": "^5.9.3", + "uuid": "^13.0.0", + "video.js": "^8.23.4", + "zod": "^4.3.6", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", - "@next/eslint-plugin-next": "^16.1.0", - "@playwright/test": "^1.54.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.3.0", + "@next/eslint-plugin-next": "^16.1.6", + "@playwright/test": "^1.58.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", + "@types/node": "^22.15.21", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", - "@types/uuid": "^10.0.0", - "eslint-config-next": "16.0.10", + "@types/video.js": "^7.3.58", + "eslint": "^9.39.2", + "eslint-config-next": "^16.1.6", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", - "globals": "^16.5.0", - "husky": "^9.0.11", - "jest": "^30.0.4", - "jest-environment-jsdom": "^30.0.4", - "lint-staged": "^16.1.2", - "playwright": "^1.54.1", - "stylelint": "^16.2.1", + "globals": "^17.3.0", + "husky": "^9.1.7", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "lint-staged": "^16.2.7", + "playwright": "^1.58.1", + "stylelint": "^17.1.0", "stylelint-config-rational-order": "^0.1.2", - "stylelint-config-standard": "^36.0.0", - "stylelint-order": "^6.0.4", - "stylelint-scss": "^6.1.0", - "ts-jest": "^29.4.0", + "stylelint-config-standard": "^40.0.0", + "stylelint-order": "^7.0.1", + "stylelint-scss": "^7.0.0", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.54.0" } }, "../SDKs/Node/Admin": { @@ -86,21 +83,21 @@ "license": "MIT", "dependencies": { "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" + "@microsoft/signalr": "^10.0.0" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@types/react": "^19.1.8", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.0.4", - "prettier": "^3.0.0", - "ts-jest": "^29.1.0", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^9.39.2", + "jest": "^30.2.0", + "prettier": "^3.7.4", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "tsup": "^8.0.0", - "typescript": "^5.8.3" + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "engines": { "node": ">=16.0.0" @@ -119,62 +116,37 @@ "version": "0.2.0", "license": "MIT", "dependencies": { - "@microsoft/signalr": "^8.0.7", - "@microsoft/signalr-protocol-msgpack": "^8.0.7" + "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr-protocol-msgpack": "^10.0.0" }, "devDependencies": { - "@types/node": "^24.0.15", - "tsup": "^8.1.0", - "typescript": "^5.8.3" + "@types/node": "^25.0.3", + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "peerDependencies": { "typescript": ">=4.5.0" } }, - "../SDKs/Node/Core": { - "name": "@knn_labs/conduit-core-client", - "version": "0.2.1", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" - }, - "devDependencies": { - "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.1.1", - "ts-jest": "^29.1.1", - "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "typescript": "^5.8.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, "../SDKs/Node/Gateway": { "name": "@knn_labs/conduit-gateway-client", "version": "0.2.1", "license": "MIT", "dependencies": { "@knn_labs/conduit-common": "file:../Common", - "@microsoft/signalr": "^8.0.7" + "@microsoft/signalr": "^10.0.0" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^24.0.15", - "@typescript-eslint/eslint-plugin": "^8.37.0", - "@typescript-eslint/parser": "^8.37.0", - "eslint": "^9.31.0", - "jest": "^30.1.1", - "ts-jest": "^29.1.1", + "@types/node": "^25.0.3", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^9.39.2", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "typescript": "^5.8.3" + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "engines": { "node": ">=16.0.0" @@ -209,13 +181,13 @@ "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -224,9 +196,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -234,21 +206,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -264,25 +236,15 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -292,13 +254,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -308,16 +270,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -329,29 +281,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -361,9 +313,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -381,9 +333,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -401,27 +353,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -486,13 +438,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -528,13 +480,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -654,13 +606,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -670,42 +622,42 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -713,14 +665,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -733,49 +685,40 @@ "dev": true, "license": "MIT" }, - "node_modules/@cacheable/memoize": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@cacheable/memoize/-/memoize-2.0.3.tgz", - "integrity": "sha512-hl9wfQgpiydhQEIv7fkjEzTGE+tcosCXLKFDO707wYJ/78FVOlowb36djex5GdbSyeHnG62pomYLMuV/OT8Pbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cacheable/utils": "^2.0.3" - } - }, "node_modules/@cacheable/memory": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.4.tgz", - "integrity": "sha512-cCmJKCKlT1t7hNBI1+gFCwmKFd9I4pS3zqBeNGXTSODnpa0EeDmORHY8oEMTuozfdg3cgsVh8ojLaPYb6eC7Cg==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz", + "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/utils": "^2.2.0", - "@keyv/bigmap": "^1.1.0", - "hookified": "^1.12.2", - "keyv": "^5.5.3" + "@cacheable/utils": "^2.3.3", + "@keyv/bigmap": "^1.3.0", + "hookified": "^1.14.0", + "keyv": "^5.5.5" } }, "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.1.0.tgz", - "integrity": "sha512-MX7XIUNwVRK+hjZcAbNJ0Z8DREo+Weu9vinBOjGU1thEi9F6vPhICzBbk4CCf3eEefKRz7n6TfZXwUFZTSgj8Q==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.12.2" + "hashery": "^1.4.0", + "hookified": "^1.15.0" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "keyv": "^5.5.3" + "keyv": "^5.6.0" } }, "node_modules/@cacheable/memory/node_modules/keyv": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", - "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { @@ -783,19 +726,20 @@ } }, "node_modules/@cacheable/utils": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.2.0.tgz", - "integrity": "sha512-7xaQayO3msdVcxXLYcLU5wDqJBNdQcPPPHr6mdTEIQI7N7TbtSVVTpWOTfjyhg0L6AQwQdq7miKdWtTDBoBldQ==", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz", + "integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==", "dev": true, "license": "MIT", "dependencies": { - "keyv": "^5.5.3" + "hashery": "^1.3.0", + "keyv": "^5.6.0" } }, "node_modules/@cacheable/utils/node_modules/keyv": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", - "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { @@ -803,14 +747,13 @@ } }, "node_modules/@clerk/backend": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.27.1.tgz", - "integrity": "sha512-RPFPBuc9y9JREPfzpN5fPcinfz+8QGOt6kEORzgIntTCpciEU8e+xKkfQbVQTNzxzj+e6VZsm8/e3kFdYzCtPg==", + "version": "2.32.1", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.32.1.tgz", + "integrity": "sha512-QZpl19nUwm2Ii+7hBBwyWIW99xKLX1kzkWC61l+nSOHXJL2RBe89op5aph1QCcxjZeUnCBp1AufsSSfkF+y0hw==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.40.0", - "@clerk/types": "^4.101.7", - "cookie": "1.0.2", + "@clerk/shared": "^3.47.0", + "@clerk/types": "^4.101.18", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" }, @@ -819,32 +762,32 @@ } }, "node_modules/@clerk/clerk-react": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.59.0.tgz", - "integrity": "sha512-AlI0KShOA/rdMnHUXRL+RKUiWOuK4lItgk3gswGip+BJTTT0C5DrJ28Yzsrlcayhk5rKD+J+sal6df3rDhRBAQ==", + "version": "5.61.1", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.61.1.tgz", + "integrity": "sha512-FB6Dt6iwNR//UG/Xt61+WJKj6wtxvPtrF4CgO3Vm3GWb6xyFPZUFRrcdE4pZrF1glCVZ1TXEAAvDMFOAM4ybRw==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.40.0", + "@clerk/shared": "^3.47.0", "tslib": "2.8.1" }, "engines": { "node": ">=18.17.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "node_modules/@clerk/nextjs": { - "version": "6.36.3", - "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.36.3.tgz", - "integrity": "sha512-BWXbfbqrsb3LRCfA/oHUp/0cdKkkRJfUBgQOCnvbTtzXVKmFSe2n8OxIBGPD5SHSQd2AMTt4Itmm57O/Ie1X/Q==", + "version": "6.38.2", + "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.38.2.tgz", + "integrity": "sha512-towgZ2sfRzPMFNIVBNO6xcG2vJLoDahh9J6TE0dqNTN07uOnaR9TdLlh0XCt934DzmmJUBGjeLWMrv+dslTu5Q==", "license": "MIT", "dependencies": { - "@clerk/backend": "^2.27.1", - "@clerk/clerk-react": "^5.59.0", - "@clerk/shared": "^3.40.0", - "@clerk/types": "^4.101.7", + "@clerk/backend": "^2.32.1", + "@clerk/clerk-react": "^5.61.1", + "@clerk/shared": "^3.47.0", + "@clerk/types": "^4.101.18", "server-only": "0.0.1", "tslib": "2.8.1" }, @@ -853,14 +796,14 @@ }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16", - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "node_modules/@clerk/shared": { - "version": "3.40.0", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.40.0.tgz", - "integrity": "sha512-gj06vVj5xIYjArpidyt+ej45svGpsnK+ogwdgYL1+3KdeM5RS31VohIWL0f07v6f2onqwMjvwkdOyPj1D3vO7w==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.0.tgz", + "integrity": "sha512-EDWFysptTc58X96MGQIZ3LlcMFKLG+rhIF9kf6n+wnyQDWnfuyA8I8ge7GbjfUXMf00c//A/CGSjg7t/oupUpw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -875,8 +818,8 @@ "node": ">=18.17.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "peerDependenciesMeta": { "react": { @@ -888,12 +831,12 @@ } }, "node_modules/@clerk/types": { - "version": "4.101.7", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.7.tgz", - "integrity": "sha512-1l1FUziIGozg8YRI1UOklR1PmS6HV7IJB3CAA10MOheZEJkQ2sEnjG8E/DObstIX7Zq/HB0OHViNt6c7nyTeRg==", + "version": "4.101.18", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.18.tgz", + "integrity": "sha512-huTv4ESnNK5ujCSc0vUNtK2k5xMDOP5C96qOUPB0AZyOWeMYEou5tHDua2NOlgFZAS/M+dJBOffohbiO2mLAhw==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.40.0" + "@clerk/shared": "^3.47.0" }, "engines": { "node": ">=18.17.0" @@ -1018,6 +961,23 @@ "@csstools/css-tokenizer": "^3.0.4" } }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", @@ -1038,10 +998,10 @@ "node": ">=18" } }, - "node_modules/@csstools/media-query-list-parser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", - "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "node_modules/@csstools/selector-resolve-nested": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", + "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", "dev": true, "funding": [ { @@ -1053,19 +1013,18 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", + "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "postcss-selector-parser": "^7.1.1" } }, "node_modules/@csstools/selector-specificity": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", "dev": true, "funding": [ { @@ -1079,27 +1038,16 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "postcss-selector-parser": "^7.0.0" - } - }, - "node_modules/@dual-bundle/import-meta-resolve": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", - "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/JounQin" + "postcss-selector-parser": "^7.1.1" } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -1109,9 +1057,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "license": "MIT", "optional": true, "dependencies": { @@ -1130,9 +1078,10 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -1147,22 +1096,37 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1170,41 +1134,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -1214,19 +1161,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1236,20 +1184,11 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1258,31 +1197,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1293,21 +1211,23 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1315,31 +1235,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react": { - "version": "0.27.16", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", - "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "version": "0.27.18", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.18.tgz", + "integrity": "sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/react-dom": "^2.1.7", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, @@ -1349,12 +1269,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -1388,6 +1308,7 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -1397,6 +1318,7 @@ "version": "0.16.7", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", @@ -1410,6 +1332,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -1423,6 +1346,7 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -1958,9 +1882,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -2034,17 +1958,17 @@ } }, "node_modules/@jest/console": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.1.2.tgz", - "integrity": "sha512-BGMAxj8VRmoD0MoA/jo9alMXSRoqW8KPeqOfEo1ncxnRLatTBCpRoOwlwlEMdudp68Q6WSGwYrrLtTGOh8fLzw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -2052,39 +1976,39 @@ } }, "node_modules/@jest/core": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.1.3.tgz", - "integrity": "sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", + "@jest/console": "30.2.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.1.3", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.0.5", - "jest-config": "30.1.3", - "jest-haste-map": "30.1.0", - "jest-message-util": "30.1.0", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-resolve-dependencies": "30.1.3", - "jest-runner": "30.1.3", - "jest-runtime": "30.1.3", - "jest-snapshot": "30.1.2", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", - "jest-watcher": "30.1.3", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", "micromatch": "^4.0.8", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -2113,9 +2037,9 @@ } }, "node_modules/@jest/core/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -2145,35 +2069,35 @@ } }, "node_modules/@jest/environment": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.2.tgz", - "integrity": "sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.1.2", - "@jest/types": "30.0.5", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "30.0.5" + "jest-mock": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.1.2.tgz", - "integrity": "sha512-u8kTh/ZBl97GOmnGJLYK/1GuwAruMC4hoP6xuk/kwltmVWsA9u/6fH1/CsPVGt2O+Wn2yEjs8n1B1zZJ62Cx0w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", + "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/fake-timers": "30.1.2", - "@jest/types": "30.0.5", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2189,23 +2113,23 @@ } }, "node_modules/@jest/expect": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.1.2.tgz", - "integrity": "sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.1.2", - "jest-snapshot": "30.1.2" + "expect": "30.2.0", + "jest-snapshot": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.2.tgz", - "integrity": "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, "license": "MIT", "dependencies": { @@ -2216,18 +2140,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.2.tgz", - "integrity": "sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2244,16 +2168,16 @@ } }, "node_modules/@jest/globals": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.1.2.tgz", - "integrity": "sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/expect": "30.1.2", - "@jest/types": "30.0.5", - "jest-mock": "30.0.5" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2274,17 +2198,17 @@ } }, "node_modules/@jest/reporters": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.1.3.tgz", - "integrity": "sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -2297,9 +2221,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -2330,13 +2254,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.2.tgz", - "integrity": "sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -2361,14 +2285,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.1.3.tgz", - "integrity": "sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -2377,15 +2301,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.1.3.tgz", - "integrity": "sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.1.3", + "@jest/test-result": "30.2.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -2393,23 +2317,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.1.2.tgz", - "integrity": "sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.0", + "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "micromatch": "^4.0.8", "pirates": "^4.0.7", "slash": "^3.0.0", @@ -2420,9 +2344,9 @@ } }, "node_modules/@jest/types": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", - "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", "dependencies": { @@ -2508,13 +2432,13 @@ "link": true }, "node_modules/@mantine/carousel": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/carousel/-/carousel-8.3.1.tgz", - "integrity": "sha512-iPl4UZd2W6rJVmYIV3RkJDoax84xhR56TCqNu4ORj46MBccNBb2bHW5h3KJHzZIYws+yK+p0yOpF9vEAVGxqCg==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/carousel/-/carousel-8.3.15.tgz", + "integrity": "sha512-RKL0uHNj4bmirh0Hob/DWEPx2IIvB91VhW7TxIXfCnPEUnxTDNsrkR8eWKox8/kgGcKRnLUqjC1Zy+bX0PZrEA==", "license": "MIT", "peerDependencies": { - "@mantine/core": "8.3.1", - "@mantine/hooks": "8.3.1", + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", "embla-carousel": ">=8.0.0", "embla-carousel-react": ">=8.0.0", "react": "^18.x || ^19.x", @@ -2522,37 +2446,37 @@ } }, "node_modules/@mantine/charts": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.3.1.tgz", - "integrity": "sha512-Mb6rSbDbcL2lQmSVZA3dZfJf3734qsdN+UeZ8vAoh00e1hJEzu6hT0SUimP7G16q1yMaB+6bgN76lOQsG8vRug==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.3.15.tgz", + "integrity": "sha512-dXn7tymDhsXezKmFu5IV2It2zb0aXGS167T0EPTthVXGBPJfb6VRaFLrq0Diyc5hLJ+q1JK9GSVuR50DSH+xyA==", "license": "MIT", "peerDependencies": { - "@mantine/core": "8.3.1", - "@mantine/hooks": "8.3.1", + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x", "recharts": ">=2.13.3" } }, "node_modules/@mantine/code-highlight": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-8.3.1.tgz", - "integrity": "sha512-YRjMuLGnNg8BlzYg1+Dj3ZW3sb4q0P9QBNZwGdKpe4x0dtLOPa3pVPnKWhSiD4/Y0cWUbCiyzUQ+MlzFYnAg9w==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-8.3.15.tgz", + "integrity": "sha512-N15ZNf/zJXfr/Nq5DRCfuhT22rIIJ54Rdfm8du5/c953B9+kfKVDEGZGh7SVrcXfo9sz7o5tLgQmlVRuSkgYuw==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { - "@mantine/core": "8.3.1", - "@mantine/hooks": "8.3.1", + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/core": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.1.tgz", - "integrity": "sha512-OYfxn9cTv+K6RZ8+Ozn/HDQXkB8Fmn+KJJt5lxyFDP9F09EHnC59Ldadv1LyUZVBGtNqz4sn6b3vBShbxwAmYw==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", + "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.16", @@ -2563,31 +2487,31 @@ "type-fest": "^4.41.0" }, "peerDependencies": { - "@mantine/hooks": "8.3.1", + "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/dates": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.1.tgz", - "integrity": "sha512-qCGlLnrwu9eQsl+yQC/tEYgTEO8rE6hopagNpTV2/wzLBUywlL/AbtB1yHuOikQgZxXAOLfvIBWNTWUHRtTnfw==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.15.tgz", + "integrity": "sha512-4WlGHCOAE4in88rQFNlPVl14e7WFWb+YBqxmx4rvAXLj9xLgUxYJO44fva1eIOwNPlTqwbx+GgsEr/HwlcmDMg==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { - "@mantine/core": "8.3.1", - "@mantine/hooks": "8.3.1", + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/form": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.1.tgz", - "integrity": "sha512-kmnF5o0Tl/Wi+ZGdqNknoN7QDswxuRo7OlPDRwXuxv/TcazuOIwf7j0p6kFzJ0c/wuqrZfjx3vnOg4Txtmwa1g==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.15.tgz", + "integrity": "sha512-A6S70KSPjkKkuXxplqTQbPJZ/pkVfJXU/I5bnsSpGacTJxUlU6KR9Ez+Wwea+NHsupl2MHks98oC0f/UiqWbwQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2598,70 +2522,70 @@ } }, "node_modules/@mantine/hooks": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.1.tgz", - "integrity": "sha512-lQutBS+Q0iz/cNFvdrsYassPWo3RtWcmDGJeOtKfHigLzFOhxUuLOkQgepDbMf3WcVMB/tist6Px1PQOv57JTw==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", + "integrity": "sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" } }, "node_modules/@mantine/modals": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-8.3.1.tgz", - "integrity": "sha512-3+OL1VcrKI91eqfLR4j6gIKHxwCVINNBrBdIVKc4ozAPAF/XI5VXwhXYxV/Nd7B2lxQgsOlIK5rjEKFvTfHZBg==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-8.3.15.tgz", + "integrity": "sha512-2071LNa203BX0S/rgn0Q0v9H5ou+3qM4O+6tzYRqiNweQLWDUyIwQRjcWTm64X7qORRWl5IFzgp5hySLhCFfGw==", "license": "MIT", "peerDependencies": { - "@mantine/core": "8.3.1", - "@mantine/hooks": "8.3.1", + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/notifications": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.1.tgz", - "integrity": "sha512-C1Iqa4g1HNNTLv2/CxOCR1mNlYNFCNtnS0u/JsR+HvtFVrun1namxDG6e6/U0hIva2klogYdivx4cyxmjPFerg==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.15.tgz", + "integrity": "sha512-CJGSv8oeLWyJIVPninU7Ud6vV6/UJKWZJwRGBNg2K0Ak0U0coFN3gW3H6G1Mh2zllNxb3K4fpMJNz4Iy0sCBFw==", "license": "MIT", "dependencies": { - "@mantine/store": "8.3.1", + "@mantine/store": "8.3.15", "react-transition-group": "4.4.5" }, "peerDependencies": { - "@mantine/core": "8.3.1", - "@mantine/hooks": "8.3.1", + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/spotlight": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/spotlight/-/spotlight-8.3.1.tgz", - "integrity": "sha512-Efmvk/uiG4MhmlkUGBu7afz5BgBDMwKUJMhMThDKZkaZfp7/VxOhHNEfC5ZPYMYd5Nk5i8Wo0urfybIMRwyO2A==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/spotlight/-/spotlight-8.3.15.tgz", + "integrity": "sha512-zKssw/6eBmkY+1sGAgD8Vpy7dU5MXcY/cpvfr65SfIknRljKM9D4Z9TflzgIpxEdhvozls06MPcxj/pZkGpELQ==", "license": "MIT", "dependencies": { - "@mantine/store": "8.3.1" + "@mantine/store": "8.3.15" }, "peerDependencies": { - "@mantine/core": "8.3.1", - "@mantine/hooks": "8.3.1", + "@mantine/core": "8.3.15", + "@mantine/hooks": "8.3.15", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/store": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.1.tgz", - "integrity": "sha512-OZwg0YKbCEKnkFmS9oRLKA8TMriBzO1T6nUib1yfLCx0VFuznllYZiDtaSWNkEYSdnFWCv5hKh5aOD4RHUnQfQ==", + "version": "8.3.15", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.15.tgz", + "integrity": "sha512-wdx91a73dM2G02YPIZ9i5UXPWfvjdf3qPAwSGnSsBFQg5uM/5CcPAOOQwlYIkvX1edUA5BFOk/4IjpEXSYUDeQ==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" } }, "node_modules/@microsoft/signalr": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz", - "integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -2672,12 +2596,12 @@ } }, "node_modules/@microsoft/signalr-protocol-msgpack": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@microsoft/signalr-protocol-msgpack/-/signalr-protocol-msgpack-9.0.6.tgz", - "integrity": "sha512-vzl00Kjs7Prw9GLDNEOnlXH3dsewjMHjl75h2CHPkbaK51AFUCRPmGXe5xW0WGDw5RTtHv1rFQdhydRIslWppQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr-protocol-msgpack/-/signalr-protocol-msgpack-10.0.0.tgz", + "integrity": "sha512-N4h4BD+y9kw/iszpDaDaIRJpxaRSA5uBtveM6HUIwmwkeJIPOoMrPNvmj77UrjZHAsbVwa/acLiWnPDfffO3yQ==", "license": "MIT", "dependencies": { - "@microsoft/signalr": ">=9.0.6", + "@microsoft/signalr": ">=10.0.0", "@msgpack/msgpack": "^2.7.0" } }, @@ -2725,55 +2649,25 @@ } }, "node_modules/@next/env": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", - "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.0.tgz", - "integrity": "sha512-sooC/k0LCF4/jLXYHpgfzJot04lZQqsttn8XJpTguP8N3GhqXN3wSkh68no2OcZzS/qeGwKDFTqhZ8WofdXmmQ==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", "dev": true, "license": "MIT", "dependencies": { "fast-glob": "3.3.1" } }, - "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz", - "integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", "cpu": [ "arm64" ], @@ -2787,9 +2681,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz", - "integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", "cpu": [ "x64" ], @@ -2803,9 +2697,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz", - "integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", "cpu": [ "arm64" ], @@ -2819,9 +2713,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz", - "integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", "cpu": [ "arm64" ], @@ -2835,9 +2729,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz", - "integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", "cpu": [ "x64" ], @@ -2851,9 +2745,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz", - "integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", "cpu": [ "x64" ], @@ -2867,9 +2761,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz", - "integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", "cpu": [ "arm64" ], @@ -2883,9 +2777,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", - "integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", "cpu": [ "x64" ], @@ -2971,13 +2865,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", - "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.55.0" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -2987,15 +2881,15 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", - "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", - "immer": "^10.0.3", + "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" @@ -3013,6 +2907,17 @@ } } }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3021,12 +2926,25 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -3054,9 +2972,9 @@ "license": "MIT" }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT", "peer": true }, @@ -3077,9 +2995,9 @@ } }, "node_modules/@tabler/icons": { - "version": "3.34.1", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.34.1.tgz", - "integrity": "sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.37.1.tgz", + "integrity": "sha512-neLCWkuyNHEPXCyYu6nbN4S3g/59BTa4qyITAugYVpq1YzYNDOZooW7/vRWH98ZItXAudxdKU8muFT7y1PqzuA==", "license": "MIT", "funding": { "type": "github", @@ -3087,12 +3005,12 @@ } }, "node_modules/@tabler/icons-react": { - "version": "3.34.1", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.34.1.tgz", - "integrity": "sha512-Ld6g0NqOO05kyyHsfU8h787PdHBm7cFmOycQSIrGp45XcXYDuOK2Bs0VC4T2FWSKZ6bx5g04imfzazf/nqtk1A==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.37.1.tgz", + "integrity": "sha512-R7UE71Jji7i4Su56Y9zU1uYEBakUejuDJvyuYVmBuUoqp/x3Pn4cv2huarexR3P0GJ2eHg4rUj9l5zccqS6K/Q==", "license": "MIT", "dependencies": { - "@tabler/icons": "3.34.1" + "@tabler/icons": "" }, "funding": { "type": "github", @@ -3103,9 +3021,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.87.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.4.tgz", - "integrity": "sha512-uNsg6zMxraEPDVO2Bn+F3/ctHi+Zsk+MMpcN8h6P7ozqD088F6mFY5TfGM7zuyIrL7HKpDyu6QHfLWiDxh3cuw==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -3113,12 +3031,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.87.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.4.tgz", - "integrity": "sha512-T5GT/1ZaNsUXf5I3RhcYuT17I4CPlbZgyLxc/ZGv7ciS6esytlbjb3DgUFO6c8JWYMDpdjSWInyGZUErgzqhcA==", + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.87.4" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -3129,12 +3047,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", - "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.19.tgz", + "integrity": "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.12" + "@tanstack/virtual-core": "3.13.19" }, "funding": { "type": "github", @@ -3146,9 +3064,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.12", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", - "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz", + "integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==", "license": "MIT", "funding": { "type": "github", @@ -3177,9 +3095,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", - "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -3204,9 +3122,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { @@ -3232,9 +3150,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "dev": true, "license": "MIT" }, @@ -3372,9 +3290,9 @@ } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "peer": true, "dependencies": { @@ -3491,9 +3409,9 @@ } }, "node_modules/@types/jest/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -3528,6 +3446,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -3560,30 +3479,38 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz", - "integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==", + "version": "22.19.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.12.tgz", + "integrity": "sha512-0QEp0aPJYSyf6RrTjDB7HlKgNMTY+V2C7ESTaVt6G9gQ0rPLzTGz7OF2NXTLR5vcy7HJEtIUsyWLsfX0kTqJBA==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.11.0" + "undici-types": "~6.21.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/react": { - "version": "19.1.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", - "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", - "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -3596,6 +3523,12 @@ "@types/react": "*" } }, + "node_modules/@types/react/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3622,13 +3555,6 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/vfile": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/vfile/-/vfile-3.0.2.tgz", @@ -3656,12 +3582,13 @@ "version": "7.3.58", "resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.58.tgz", "integrity": "sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -3676,19 +3603,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", - "integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/type-utils": "8.50.0", - "@typescript-eslint/utils": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3698,22 +3626,33 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.50.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", - "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", - "debug": "^4.3.4" + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3723,19 +3662,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", - "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.50.0", - "@typescript-eslint/types": "^8.50.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3749,13 +3689,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz", - "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3766,9 +3707,10 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", - "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3782,16 +3724,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz", - "integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/utils": "8.50.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3801,14 +3744,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", - "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3819,20 +3763,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", - "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.50.0", - "@typescript-eslint/tsconfig-utils": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3845,16 +3790,69 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz", - "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3864,18 +3862,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", - "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3886,12 +3885,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -4173,9 +4173,9 @@ ] }, "node_modules/@videojs/http-streaming": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz", - "integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==", + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.4.tgz", + "integrity": "sha512-XAvdG2dolBuV2Fx8bu1kjmQ2D4TonGzZH68Pgv/O9xMSFWdZtITSMFismeQLEAtMmGwze8qNJp3RgV+jStrJqg==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", @@ -4242,9 +4242,10 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4257,15 +4258,16 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -4298,9 +4300,10 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4356,6 +4359,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4392,6 +4396,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -4485,13 +4490,16 @@ } }, "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", "dev": true, "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, "node_modules/array-uniq": { @@ -4765,9 +4773,9 @@ } }, "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4775,13 +4783,13 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -4796,16 +4804,16 @@ } }, "node_modules/babel-jest": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", - "integrity": "sha512-IQCus1rt9kaSh7PQxLYRY5NmkNrNlU2TpabzwV7T2jljnpdHOcmnYYv8QmE04Li4S3a2Lj8/yXyET5pBarPr6g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.1.2", + "@jest/transform": "30.2.0", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.0", - "babel-preset-jest": "30.0.1", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -4814,7 +4822,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "node_modules/babel-plugin-istanbul": { @@ -4838,14 +4846,12 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", - "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", "@types/babel__core": "^7.20.5" }, "engines": { @@ -4880,20 +4886,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", - "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.0.1", - "babel-preset-current-node-syntax": "^1.1.0" + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/bail": { @@ -4910,6 +4916,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base": { @@ -4945,22 +4952,26 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.3.tgz", - "integrity": "sha512-mcE+Wr2CAhHNWxXN/DdTI+n4gsPc5QpXpWnyCQWiQYIYZX+ZMJ8juXZgjRa/0/YPJo/NSsgW15/YgmI4nbysYw==", - "dev": true, + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -4977,9 +4988,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", - "integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -4997,11 +5008,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.2", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -5062,24 +5073,23 @@ } }, "node_modules/cacheable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.1.1.tgz", - "integrity": "sha512-LmF4AXiSNdiRbI2UjH8pAp9NIXxeQsTotpEaegPiDcnN0YPygDJDV3l/Urc0mL72JWdATEorKqIHEx55nDlONg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz", + "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/memoize": "^2.0.3", - "@cacheable/memory": "^2.0.3", - "@cacheable/utils": "^2.1.0", - "hookified": "^1.12.2", - "keyv": "^5.5.3", - "qified": "^0.5.0" + "@cacheable/memory": "^2.0.7", + "@cacheable/utils": "^2.3.3", + "hookified": "^1.15.0", + "keyv": "^5.5.5", + "qified": "^0.6.0" } }, "node_modules/cacheable/node_modules/keyv": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", - "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { @@ -5182,6 +5192,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5223,9 +5234,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001741", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", - "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "funding": [ { "type": "opencollective", @@ -5256,6 +5267,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -5319,9 +5331,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -5335,9 +5347,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", - "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, "license": "MIT" }, @@ -5401,9 +5413,9 @@ } }, "node_modules/cli-truncate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.0.0.tgz", - "integrity": "sha512-ds7u02fPOOBpcUl2VSjLF3lfnAik9u7Zt0BTaaAQlT5RtABALl4cvpJHthXx+rM50J4gSfXKPH5Tix/tfdefUQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", "dev": true, "license": "MIT", "dependencies": { @@ -5418,14 +5430,14 @@ } }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -5564,9 +5576,9 @@ } }, "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, @@ -5588,6 +5600,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5600,6 +5613,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -5639,9 +5653,9 @@ } }, "node_modules/commander": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", - "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { @@ -5662,6 +5676,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -5671,15 +5686,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -5728,6 +5734,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5748,13 +5755,13 @@ } }, "node_modules/css-functions-list": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", - "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12 || >=16" + "node": ">=12" } }, "node_modules/css-tree": { @@ -5858,9 +5865,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "peer": true, "engines": { @@ -6042,9 +6049,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.18", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", - "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT", "peer": true }, @@ -6117,9 +6124,9 @@ "peer": true }, "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -6140,9 +6147,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6158,6 +6165,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, "license": "MIT" }, "node_modules/deepmerge": { @@ -6278,9 +6286,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6288,16 +6296,16 @@ } }, "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", "dev": true, "license": "MIT", "dependencies": { - "path-type": "^4.0.0" + "path-type": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/doctrine": { @@ -6433,9 +6441,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.218", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", - "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -6527,9 +6535,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6537,9 +6545,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -6624,27 +6632,27 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.1", "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", + "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", + "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", + "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" }, "engines": { @@ -6710,9 +6718,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", - "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", "license": "MIT", "peer": true, "workspaces": [ @@ -6734,6 +6742,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6743,24 +6752,24 @@ } }, "node_modules/eslint": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", - "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.35.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -6803,13 +6812,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.10.tgz", - "integrity": "sha512-BxouZUm0I45K4yjOOIzj24nTi0H2cGo0y7xUmk+Po/PYtJXFBYVDS1BguE7t28efXjKdcN0tmiLivxQy//SsZg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.0.10", + "@next/eslint-plugin-next": "16.1.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -6829,46 +6838,6 @@ } } }, - "node_modules/eslint-config-next/node_modules/@next/eslint-plugin-next": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.10.tgz", - "integrity": "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "3.3.1" - } - }, - "node_modules/eslint-config-next/node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/eslint-config-next/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/eslint-config-next/node_modules/globals": { "version": "16.4.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", @@ -6997,16 +6966,6 @@ "node": ">=0.8.0" } }, - "node_modules/eslint-plugin-eslint-comments/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -7041,17 +7000,6 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -7062,29 +7010,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", @@ -7125,30 +7050,6 @@ "node": ">= 0.4" } }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -7202,62 +7103,35 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -7271,43 +7145,10 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", - "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7316,31 +7157,11 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", @@ -7354,18 +7175,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -7381,9 +7190,10 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -7396,6 +7206,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -7408,6 +7219,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -7427,6 +7239,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -7442,9 +7255,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/eventsource": { @@ -7597,18 +7410,18 @@ "license": "MIT" }, "node_modules/expect": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", - "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.1.2", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7697,9 +7510,9 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dev": true, "license": "MIT", "dependencies": { @@ -7707,7 +7520,7 @@ "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "micromatch": "^4.0.4" }, "engines": { "node": ">=8.6.0" @@ -7730,12 +7543,14 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, "license": "MIT" }, "node_modules/fast-sha256": { @@ -7772,9 +7587,9 @@ } }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -7818,6 +7633,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" @@ -7843,6 +7659,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -7859,6 +7676,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", @@ -7872,6 +7690,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { @@ -7938,9 +7757,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8036,6 +7855,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8057,9 +7886,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "dev": true, "license": "MIT", "engines": { @@ -8167,9 +7996,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "dev": true, "license": "MIT", "dependencies": { @@ -8190,9 +8019,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -8214,6 +8044,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -8228,6 +8059,45 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", + "integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", @@ -8280,9 +8150,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, "license": "MIT", "engines": { @@ -8310,36 +8180,79 @@ } }, "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.1.1.tgz", + "integrity": "sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==", "dev": true, "license": "MIT", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/globby/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/globby/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { "node": ">= 4" } }, + "node_modules/globby/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globjoin": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", @@ -8421,6 +8334,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8557,6 +8471,19 @@ "node": ">=0.10.0" } }, + "node_modules/hashery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz", + "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.14.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8570,10 +8497,13 @@ } }, "node_modules/hast-util-parse-selector": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", - "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" @@ -8620,70 +8550,22 @@ } }, "node_modules/hastscript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", - "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", "license": "MIT", "dependencies": { - "@types/hast": "^2.0.0", - "comma-separated-tokens": "^1.0.0", - "hast-util-parse-selector": "^2.0.0", - "property-information": "^5.0.0", - "space-separated-tokens": "^1.0.0" + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" } }, - "node_modules/hastscript/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/hastscript/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/hastscript/node_modules/comma-separated-tokens": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", - "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/hastscript/node_modules/property-information": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", - "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/hastscript/node_modules/space-separated-tokens": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", - "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -8717,9 +8599,9 @@ "license": "CC0-1.0" }, "node_modules/hookified": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.2.tgz", - "integrity": "sha512-aokUX1VdTpI0DUsndvW+OiwmBpKCu/NgRsSSkuSY0zq8PY6Q6a+lmOfAFDXAAOtBqJELvcWY9L1EVtzjbQcMdg==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, "license": "MIT" }, @@ -8751,13 +8633,13 @@ "license": "MIT" }, "node_modules/html-tags": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", + "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20.10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8863,18 +8745,19 @@ } }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/immer": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", - "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", "peer": true, "funding": { @@ -8886,6 +8769,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -8928,10 +8812,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -8981,9 +8877,9 @@ "license": "ISC" }, "node_modules/inline-style-parser": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", - "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, "node_modules/internal-slot": { @@ -9170,6 +9066,19 @@ "semver": "^7.7.1" } }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -9311,6 +9220,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9365,14 +9275,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -9387,6 +9298,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9468,6 +9380,19 @@ "node": ">=8" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -9718,6 +9643,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -9757,6 +9683,19 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -9836,16 +9775,16 @@ } }, "node_modules/jest": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.1.3.tgz", - "integrity": "sha512-Ry+p2+NLk6u8Agh5yVqELfUJvRfV51hhVBRIB5yZPY7mU0DGBmOuFG5GebZbMbm86cdQNK0fhJuDX8/1YorISQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.1.3", - "@jest/types": "30.0.5", + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", "import-local": "^3.2.0", - "jest-cli": "30.1.3" + "jest-cli": "30.2.0" }, "bin": { "jest": "bin/jest.js" @@ -9863,14 +9802,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.5.tgz", - "integrity": "sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "p-limit": "^3.1.0" }, "engines": { @@ -9878,29 +9817,29 @@ } }, "node_modules/jest-circus": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.1.3.tgz", - "integrity": "sha512-Yf3dnhRON2GJT4RYzM89t/EXIWNxKTpWTL9BfF3+geFetWP4XSvJjiU1vrWplOiUkmq8cHLiwuhz+XuUp9DscA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/expect": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/types": "30.0.5", + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.1.0", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-runtime": "30.1.3", - "jest-snapshot": "30.1.2", - "jest-util": "30.0.5", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "p-limit": "^3.1.0", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -9923,9 +9862,9 @@ } }, "node_modules/jest-circus/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -9945,21 +9884,21 @@ "license": "MIT" }, "node_modules/jest-cli": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.1.3.tgz", - "integrity": "sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.1.3", - "@jest/test-result": "30.1.3", - "@jest/types": "30.0.5", + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.1.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "yargs": "^17.7.2" }, "bin": { @@ -9978,34 +9917,34 @@ } }, "node_modules/jest-config": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.1.3.tgz", - "integrity": "sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.1.3", - "@jest/types": "30.0.5", - "babel-jest": "30.1.2", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-circus": "30.1.3", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.2", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-runner": "30.1.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -10043,9 +9982,9 @@ } }, "node_modules/jest-config/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10065,16 +10004,16 @@ "license": "MIT" }, "node_modules/jest-diff": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", - "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10094,9 +10033,9 @@ } }, "node_modules/jest-diff/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10116,9 +10055,9 @@ "license": "MIT" }, "node_modules/jest-docblock": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", - "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", "dependencies": { @@ -10129,17 +10068,17 @@ } }, "node_modules/jest-each": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.1.0.tgz", - "integrity": "sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "chalk": "^4.1.2", - "jest-util": "30.0.5", - "pretty-format": "30.0.5" + "jest-util": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10159,9 +10098,9 @@ } }, "node_modules/jest-each/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10181,14 +10120,14 @@ "license": "MIT" }, "node_modules/jest-environment-jsdom": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.1.2.tgz", - "integrity": "sha512-LXsfAh5+mDTuXDONGl1ZLYxtJEaS06GOoxJb2arcJTjIfh1adYg8zLD8f6P0df8VmjvCaMrLmc1PgHUI/YUTbg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", + "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/environment-jsdom-abstract": "30.1.2", + "@jest/environment": "30.2.0", + "@jest/environment-jsdom-abstract": "30.2.0", "@types/jsdom": "^21.1.7", "@types/node": "*", "jsdom": "^26.1.0" @@ -10206,39 +10145,39 @@ } }, "node_modules/jest-environment-node": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.1.2.tgz", - "integrity": "sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/fake-timers": "30.1.2", - "@jest/types": "30.0.5", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "30.0.5", - "jest-util": "30.0.5", - "jest-validate": "30.1.0" + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.1.0.tgz", - "integrity": "sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "micromatch": "^4.0.8", "walker": "^1.0.8" }, @@ -10250,14 +10189,14 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.1.0.tgz", - "integrity": "sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10277,9 +10216,9 @@ } }, "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10299,16 +10238,16 @@ "license": "MIT" }, "node_modules/jest-matcher-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", - "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.1.2", - "pretty-format": "30.0.5" + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10328,9 +10267,9 @@ } }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10350,19 +10289,19 @@ "license": "MIT" }, "node_modules/jest-message-util": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", - "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -10384,9 +10323,9 @@ } }, "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10406,15 +10345,15 @@ "license": "MIT" }, "node_modules/jest-mock": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", - "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-util": "30.0.5" + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10449,18 +10388,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.1.3.tgz", - "integrity": "sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -10469,46 +10408,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.1.3.tgz", - "integrity": "sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.1.2" + "jest-snapshot": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.1.3.tgz", - "integrity": "sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", - "@jest/environment": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.2", - "jest-haste-map": "30.1.0", - "jest-leak-detector": "30.1.0", - "jest-message-util": "30.1.0", - "jest-resolve": "30.1.3", - "jest-runtime": "30.1.3", - "jest-util": "30.0.5", - "jest-watcher": "30.1.3", - "jest-worker": "30.1.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -10517,32 +10456,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.1.3.tgz", - "integrity": "sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/fake-timers": "30.1.2", - "@jest/globals": "30.1.2", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-snapshot": "30.1.2", - "jest-util": "30.0.5", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -10551,9 +10490,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.2.tgz", - "integrity": "sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", "dependencies": { @@ -10562,20 +10501,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.1.2", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.1.2", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", - "babel-preset-current-node-syntax": "^1.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.1.2", + "expect": "30.2.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.1.2", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "pretty-format": "30.0.5", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -10597,9 +10536,9 @@ } }, "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10618,14 +10557,27 @@ "dev": true, "license": "MIT" }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest-util": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", - "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -10650,18 +10602,18 @@ } }, "node_modules/jest-validate": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.1.0.tgz", - "integrity": "sha512-7P3ZlCFW/vhfQ8pE7zW6Oi4EzvuB4sgR72Q1INfW9m0FGo0GADYlPwIkf4CyPq7wq85g+kPMtPOHNAdWHeBOaA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10694,9 +10646,9 @@ } }, "node_modules/jest-validate/node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -10716,19 +10668,19 @@ "license": "MIT" }, "node_modules/jest-watcher": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.1.3.tgz", - "integrity": "sha512-6jQUZCP1BTL2gvG9E4YF06Ytq4yMb4If6YoQGRR6PpjtqOXSP3sKe2kqwB6SQ+H9DezOfZaSLnmka1NtGm3fCQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.1.3", - "@jest/types": "30.0.5", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "string-length": "^4.0.2" }, "engines": { @@ -10736,15 +10688,15 @@ } }, "node_modules/jest-worker": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.1.0.tgz", - "integrity": "sha512-uvWcSjlwAAgIu133Tt77A05H7RIk3Ho8tZL50bQM2AkvLdluw9NG48lRCl3Dt+MOH719n/0nnb5YxUwcuJiKRA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -10787,6 +10739,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10849,9 +10802,9 @@ } }, "node_modules/jsdom/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -10887,6 +10840,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-parse-better-errors": { @@ -10907,12 +10861,14 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, "license": "MIT" }, "node_modules/json5": { @@ -10948,6 +10904,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -11013,6 +10970,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -11022,19 +10980,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -11043,19 +10988,16 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.6.tgz", - "integrity": "sha512-U4kuulU3CKIytlkLlaHcGgKscNfJPNTiDF2avIUGFCv7K95/DCYQ7Ra62ydeRWmgQGg9zJYw2dzdbztwJlqrow==", + "version": "16.2.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^5.6.0", - "commander": "^14.0.0", - "debug": "^4.4.1", - "lilconfig": "^3.1.3", - "listr2": "^9.0.3", + "commander": "^14.0.2", + "listr2": "^9.0.5", "micromatch": "^4.0.8", - "nano-spawn": "^1.0.2", + "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" @@ -11070,23 +11012,10 @@ "url": "https://opencollective.com/lint-staged" } }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/listr2": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.4.tgz", - "integrity": "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { @@ -11115,9 +11044,9 @@ } }, "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -11211,6 +11140,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -11223,9 +11153,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -11240,6 +11170,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.truncate": { @@ -11361,9 +11292,9 @@ } }, "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.0.tgz", - "integrity": "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { @@ -11390,9 +11321,9 @@ } }, "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -11537,6 +11468,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -11618,9 +11562,9 @@ } }, "node_modules/mathml-tag-names": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", - "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", + "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==", "dev": true, "license": "MIT", "funding": { @@ -11698,9 +11642,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -11897,9 +11841,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -11959,13 +11903,13 @@ "license": "CC0-1.0" }, "node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12610,9 +12554,10 @@ } }, "node_modules/min-document": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", - "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", "dependencies": { "dom-walk": "^0.1.0" } @@ -12628,18 +12573,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { @@ -12677,11 +12620,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -12752,9 +12695,9 @@ } }, "node_modules/nano-spawn": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz", - "integrity": "sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", "dev": true, "license": "MIT", "engines": { @@ -12806,9 +12749,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { @@ -12825,6 +12768,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, "license": "MIT" }, "node_modules/neo-async": { @@ -12835,13 +12779,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz", - "integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", "dependencies": { - "@next/env": "16.0.10", + "@next/env": "16.1.6", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -12853,14 +12798,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.10", - "@next/swc-darwin-x64": "16.0.10", - "@next/swc-linux-arm64-gnu": "16.0.10", - "@next/swc-linux-arm64-musl": "16.0.10", - "@next/swc-linux-x64-gnu": "16.0.10", - "@next/swc-linux-x64-musl": "16.0.10", - "@next/swc-win32-arm64-msvc": "16.0.10", - "@next/swc-win32-x64-msvc": "16.0.10", + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { @@ -12886,6 +12831,25 @@ } } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12936,9 +12900,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -13013,9 +12977,9 @@ "license": "MIT" }, "node_modules/nwsapi": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", - "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, "license": "MIT" }, @@ -13259,6 +13223,7 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -13294,6 +13259,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -13309,6 +13275,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -13341,6 +13308,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -13427,6 +13395,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13446,6 +13415,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13483,13 +13453,26 @@ "license": "ISC" }, "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", "dev": true, "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, "engines": { - "node": ">=8" + "node": ">=4" + } + }, + "node_modules/path-type/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/picocolors": { @@ -13626,13 +13609,13 @@ } }, "node_modules/playwright": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -13645,9 +13628,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -13661,6 +13644,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -14035,9 +14019,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -14049,9 +14033,9 @@ } }, "node_modules/postcss-sorting": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-8.0.2.tgz", - "integrity": "sha512-M9dkSrmU00t/jK7rF6BZSZauA5MAaBW4i5EnJXspMwt4iqTh/L9j6fgMnbElEOfyRyfLfVbIHj/R52zHzAPe1Q==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-9.1.0.tgz", + "integrity": "sha512-Mn8KJ45HNNG6JBpBizXcyf6LqY/qyqetGcou/nprDnFwBFBLGj0j/sNKV2lj2KMOVOwdXu14aEzqJv8CIV6e8g==", "dev": true, "license": "MIT", "peerDependencies": { @@ -14079,6 +14063,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8.0" @@ -14212,13 +14197,13 @@ "license": "MIT" }, "node_modules/qified": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.5.1.tgz", - "integrity": "sha512-+BtFN3dCP+IaFA6IYNOu/f/uK1B8xD2QWyOeCse0rjtAebBmkzgd2d1OAXi3ikAzJMIBSdzZDNZ3wZKEUDQs5w==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz", + "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.12.2" + "hookified": "^1.14.0" }, "engines": { "node": ">=20" @@ -14268,30 +14253,30 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-is": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", - "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT", "peer": true }, @@ -14356,9 +14341,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -14425,17 +14410,20 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "15.6.6", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", - "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==", + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.3.1", + "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", - "refractor": "^3.6.0" + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" }, "peerDependencies": { "react": ">= 0.14.0" @@ -14576,29 +14564,6 @@ "node": ">=4" } }, - "node_modules/read-pkg/node_modules/path-type": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", - "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -14615,11 +14580,14 @@ } }, "node_modules/recharts": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.0.tgz", - "integrity": "sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", "license": "MIT", "peer": true, + "workspaces": [ + "www" + ], "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", @@ -14696,121 +14664,21 @@ } }, "node_modules/refractor": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", - "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", - "license": "MIT", - "dependencies": { - "hastscript": "^6.0.0", - "parse-entities": "^2.0.0", - "prismjs": "~1.27.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/character-entities": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", - "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/character-entities-legacy": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", - "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/character-reference-invalid": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", - "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-alphabetical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", - "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-alphanumerical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", - "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^1.0.0", - "is-decimal": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-decimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", - "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/is-hexadecimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", - "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/parse-entities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", - "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", "license": "MIT", "dependencies": { - "character-entities": "^1.0.0", - "character-entities-legacy": "^1.0.0", - "character-reference-invalid": "^1.0.0", - "is-alphanumerical": "^1.0.0", - "is-decimal": "^1.0.0", - "is-hexadecimal": "^1.0.0" + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/refractor/node_modules/prismjs": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", - "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -15270,13 +15138,13 @@ "peer": true }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -15317,6 +15185,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -15415,22 +15284,11 @@ "rimraf": "bin.js" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -15448,19 +15306,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -15605,15 +15450,13 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/server-only": { @@ -15623,9 +15466,9 @@ "license": "MIT" }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/set-function-length": { @@ -15774,10 +15617,24 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15790,6 +15647,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16175,9 +16033,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -16641,6 +16499,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16657,21 +16516,21 @@ "license": "ISC" }, "node_modules/style-to-js": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", - "integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", "license": "MIT", "dependencies": { - "style-to-object": "1.0.9" + "style-to-object": "1.0.14" } }, "node_modules/style-to-object": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.9.tgz", - "integrity": "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", "license": "MIT", "dependencies": { - "inline-style-parser": "0.2.4" + "inline-style-parser": "0.2.7" } }, "node_modules/styled-jsx": { @@ -16698,9 +16557,9 @@ } }, "node_modules/stylelint": { - "version": "16.25.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.25.0.tgz", - "integrity": "sha512-Li0avYWV4nfv1zPbdnxLYBGq4z8DVZxbRgx4Kn6V+Uftz1rMoF1qiEI3oL4kgWqyYgCgs7gT5maHNZ82Gk03vQ==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.4.0.tgz", + "integrity": "sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==", "dev": true, "funding": [ { @@ -16714,50 +16573,49 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "@csstools/media-query-list-parser": "^4.0.3", - "@csstools/selector-specificity": "^5.0.0", - "@dual-bundle/import-meta-resolve": "^4.2.1", - "balanced-match": "^2.0.0", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.27", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0", + "@csstools/selector-resolve-nested": "^4.0.0", + "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.0", - "css-functions-list": "^3.2.3", + "css-functions-list": "^3.3.3", "css-tree": "^3.1.0", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^10.1.4", + "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", - "globby": "^11.1.0", + "globby": "^16.1.0", "globjoin": "^0.1.4", - "html-tags": "^3.3.1", + "html-tags": "^5.1.0", "ignore": "^7.0.5", + "import-meta-resolve": "^4.2.0", "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", - "known-css-properties": "^0.37.0", - "mathml-tag-names": "^2.1.3", - "meow": "^13.2.0", + "mathml-tag-names": "^4.0.0", + "meow": "^14.0.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.6", - "postcss-resolve-nested-selector": "^0.1.6", "postcss-safe-parser": "^7.0.1", - "postcss-selector-parser": "^7.1.0", + "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", - "resolve-from": "^5.0.0", - "string-width": "^4.2.3", - "supports-hyperlinks": "^3.2.0", + "string-width": "^8.1.1", + "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", - "write-file-atomic": "^5.0.1" + "write-file-atomic": "^7.0.0" }, "bin": { "stylelint": "bin/stylelint.mjs" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" } }, "node_modules/stylelint-config-rational-order": { @@ -16814,30 +16672,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/stylelint-config-rational-order/node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stylelint-config-rational-order/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/stylelint-config-rational-order/node_modules/braces": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", @@ -16931,19 +16765,6 @@ "node": ">=4" } }, - "node_modules/stylelint-config-rational-order/node_modules/dir-glob": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", - "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/stylelint-config-rational-order/node_modules/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -17047,7 +16868,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -17139,16 +16960,6 @@ "node": ">=4" } }, - "node_modules/stylelint-config-rational-order/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/stylelint-config-rational-order/node_modules/import-fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", @@ -17237,9 +17048,9 @@ } }, "node_modules/stylelint-config-rational-order/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -17267,6 +17078,17 @@ "node": ">=0.10.0" } }, + "node_modules/stylelint-config-rational-order/node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stylelint-config-rational-order/node_modules/meow": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz", @@ -17306,59 +17128,23 @@ "nanomatch": "^1.2.9", "object.pick": "^1.3.0", "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stylelint-config-rational-order/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/stylelint-config-rational-order/node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/stylelint-config-rational-order/node_modules/path-type": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", - "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^3.0.0" + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" }, "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/stylelint-config-rational-order/node_modules/path-type/node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "node_modules/stylelint-config-rational-order/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", "dev": true, "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, "engines": { "node": ">=4" } @@ -17655,9 +17441,9 @@ } }, "node_modules/stylelint-config-recommended": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", - "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz", + "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==", "dev": true, "funding": [ { @@ -17671,16 +17457,16 @@ ], "license": "MIT", "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" }, "peerDependencies": { - "stylelint": "^16.1.0" + "stylelint": "^17.0.0" } }, "node_modules/stylelint-config-standard": { - "version": "36.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz", - "integrity": "sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==", + "version": "40.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz", + "integrity": "sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==", "dev": true, "funding": [ { @@ -17694,27 +17480,30 @@ ], "license": "MIT", "dependencies": { - "stylelint-config-recommended": "^14.0.1" + "stylelint-config-recommended": "^18.0.0" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" }, "peerDependencies": { - "stylelint": "^16.1.0" + "stylelint": "^17.0.0" } }, "node_modules/stylelint-order": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-6.0.4.tgz", - "integrity": "sha512-0UuKo4+s1hgQ/uAxlYU4h0o0HS4NiQDud0NAUNI0aa8FJdmYHA5ZZTFHiV5FpmE3071e9pZx5j0QpVJW5zOCUA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-7.0.1.tgz", + "integrity": "sha512-GWPei1zBVDDjxM+/BmcSCiOcHNd8rSqW6FUZtqQGlTRpD0Z5nSzspzWD8rtKif5KPdzUG68DApKEV/y/I9VbTw==", "dev": true, "license": "MIT", "dependencies": { - "postcss": "^8.4.32", - "postcss-sorting": "^8.0.2" + "postcss": "^8.5.6", + "postcss-sorting": "^9.1.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { - "stylelint": "^14.0.0 || ^15.0.0 || ^16.0.1" + "stylelint": "^16.18.0 || ^17.0.0" } }, "node_modules/stylelint-order/node_modules/postcss": { @@ -17747,86 +17536,186 @@ } }, "node_modules/stylelint-scss": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.12.1.tgz", - "integrity": "sha512-UJUfBFIvXfly8WKIgmqfmkGKPilKB4L5j38JfsDd+OCg2GBdU0vGUV08Uw82tsRZzd4TbsUURVVNGeOhJVF7pA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.0.0.tgz", + "integrity": "sha512-H88kCC+6Vtzj76NsC8rv6x/LW8slBzIbyeSjsKVlS+4qaEJoDrcJR4L+8JdrR2ORdTscrBzYWiiT2jq6leYR1Q==", "dev": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.1", "is-plain-object": "^5.0.0", - "known-css-properties": "^0.36.0", - "mdn-data": "^2.21.0", + "known-css-properties": "^0.37.0", + "mdn-data": "^2.25.0", "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.6", - "postcss-selector-parser": "^7.1.0", + "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" }, "peerDependencies": { - "stylelint": "^16.0.2" + "stylelint": "^16.8.2 || ^17.0.0" } }, - "node_modules/stylelint-scss/node_modules/known-css-properties": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.36.0.tgz", - "integrity": "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==", - "dev": true, - "license": "MIT" - }, "node_modules/stylelint-scss/node_modules/mdn-data": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.25.0.tgz", - "integrity": "sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, - "node_modules/stylelint/node_modules/balanced-match": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", - "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "node_modules/stylelint/node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } }, - "node_modules/stylelint/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/stylelint/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/stylelint/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/stylelint/node_modules/@csstools/media-query-list-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", + "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/stylelint/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.1.4.tgz", - "integrity": "sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz", + "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.13" + "flat-cache": "^6.1.20" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.18", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.18.tgz", - "integrity": "sha512-JUPnFgHMuAVmLmoH9/zoZ6RHOt5n9NlUw/sDXsTbROJ2SFoS2DS4s+swAV6UTeTbGH/CAsZIE6M8TaG/3jVxgQ==", + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz", + "integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==", "dev": true, "license": "MIT", "dependencies": { - "cacheable": "^2.1.0", + "cacheable": "^2.3.2", "flatted": "^3.3.3", - "hookified": "^1.12.0" + "hookified": "^1.15.0" } }, - "node_modules/stylelint/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/stylelint/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/stylelint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 4" } }, "node_modules/stylelint/node_modules/postcss": { @@ -17858,42 +17747,35 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/stylelint/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/stylelint/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stylelint/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/stylelint/node_modules/write-file-atomic": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ansi-regex": "^5.0.1" + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/sugarss": { @@ -17935,6 +17817,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -17944,22 +17827,48 @@ } }, "node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", + "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" }, "engines": { - "node": ">=14.18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -18000,9 +17909,9 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18016,9 +17925,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/table": { @@ -18039,9 +17948,9 @@ } }, "node_modules/table/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -18150,22 +18059,11 @@ "node": ">=8" } }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -18183,19 +18081,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -18206,6 +18091,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -18222,6 +18108,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -18239,6 +18126,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -18413,9 +18301,10 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.12" @@ -18425,9 +18314,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", - "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -18437,7 +18326,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -18477,6 +18366,19 @@ } } }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -18567,6 +18469,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -18676,9 +18579,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -18689,16 +18592,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz", - "integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.50.0", - "@typescript-eslint/parser": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/utils": "8.50.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -18708,7 +18611,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -18746,9 +18649,10 @@ } }, "node_modules/undici-types": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz", - "integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unherit": { @@ -18766,6 +18670,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -18840,9 +18757,9 @@ "license": "MIT" }, "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -18920,9 +18837,9 @@ } }, "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18935,9 +18852,9 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -19052,9 +18969,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -19086,6 +19003,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -19208,9 +19126,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -19224,16 +19142,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -19332,13 +19250,13 @@ } }, "node_modules/video.js": { - "version": "8.23.4", - "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", - "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", + "version": "8.23.7", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.7.tgz", + "integrity": "sha512-cG4HOygYt+Z8j6Sf5DuK6OgEOoM+g9oGP6vpqoZRaD13aHE4PMITbyjJUXZcIQbgB0wJEadBRaVm5lJIzo2jAA==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "^3.17.2", + "@babel/runtime": "^7.28.4", + "@videojs/http-streaming": "^3.17.3", "@videojs/vhs-utils": "^4.1.1", "@videojs/xhr": "2.7.0", "aes-decrypter": "^4.0.2", @@ -19419,6 +19337,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -19456,6 +19375,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -19535,9 +19455,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -19560,6 +19480,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -19749,6 +19670,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -19772,9 +19694,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "bin": { @@ -19782,6 +19704,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { @@ -19872,6 +19797,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -19881,9 +19807,9 @@ } }, "node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -19903,9 +19829,9 @@ } }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/WebAdmin/package.json b/WebAdmin/package.json index 8bc41acc1..54b19fd61 100755 --- a/WebAdmin/package.json +++ b/WebAdmin/package.json @@ -4,7 +4,7 @@ "description": "Next.js WebAdmin for Conduit LLM Platform", "private": true, "scripts": { - "dev": "next dev --webpack", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint src", @@ -19,46 +19,39 @@ "pre-commit": "lint-staged" }, "dependencies": { - "@clerk/nextjs": "^6.36.3", + "@clerk/nextjs": "^6.37.1", "@hello-pangea/dnd": "^18.0.1", "@knn_labs/conduit-admin-client": "file:../SDKs/Node/Admin", "@knn_labs/conduit-common": "file:../SDKs/Node/Common", "@knn_labs/conduit-gateway-client": "file:../SDKs/Node/Gateway", - "@mantine/carousel": "^8.1.2", - "@mantine/charts": "^8.1.2", - "@mantine/code-highlight": "^8.1.2", - "@mantine/core": "^8.1.2", - "@mantine/dates": "^8.1.2", - "@mantine/form": "^8.1.2", - "@mantine/hooks": "^8.1.2", - "@mantine/modals": "^8.1.2", - "@mantine/notifications": "^8.1.2", - "@mantine/spotlight": "^8.1.2", - "@microsoft/signalr": "^9.0.6", - "@microsoft/signalr-protocol-msgpack": "^9.0.6", - "@tabler/icons-react": "^3.34.1", - "@tanstack/react-query": "^5.0.0", - "@tanstack/react-virtual": "^3.13.12", - "@types/node": "^24.0.15", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@types/video.js": "^7.3.58", - "@typescript-eslint/eslint-plugin": "^8.35.0", - "@typescript-eslint/parser": "^8.35.0", - "axios": "^1.10.0", + "@mantine/carousel": "^8.3.14", + "@mantine/charts": "^8.3.14", + "@mantine/code-highlight": "^8.3.14", + "@mantine/core": "^8.3.14", + "@mantine/dates": "^8.3.14", + "@mantine/form": "^8.3.14", + "@mantine/hooks": "^8.3.14", + "@mantine/modals": "^8.3.14", + "@mantine/notifications": "^8.3.14", + "@mantine/spotlight": "^8.3.14", + "@microsoft/signalr": "^10.0.0", + "@microsoft/signalr-protocol-msgpack": "^10.0.0", + "@tabler/icons-react": "^3.36.1", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-virtual": "^3.13.18", + "axios": "^1.13.4", "date-fns": "^4.1.0", - "eslint": "^9.30.0", - "next": "16.0.10", - "react": "^19.2.3", - "react-dom": "^19.2.3", + "next": "^16.1.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.1", + "react-syntax-highlighter": "^16.1.0", "remark-gfm": "^4.0.1", - "typescript": "^5.8.3", - "uuid": "^11.1.0", - "video.js": "^8.23.3", - "zod": "^4.0.5", - "zustand": "^5.0.6" + "typescript": "^5.9.3", + "uuid": "^13.0.0", + "video.js": "^8.23.4", + "zod": "^4.3.6", + "zustand": "^5.0.11" }, "keywords": [ "conduit", @@ -68,36 +61,40 @@ "typescript", "mantine" ], - "author": "KNN Labs", + "author": "Nick Nassiri", "license": "ISC", "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", - "@next/eslint-plugin-next": "^16.1.0", - "@playwright/test": "^1.54.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.3.0", + "@next/eslint-plugin-next": "^16.1.6", + "@playwright/test": "^1.58.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", + "@types/node": "^22.15.21", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", - "@types/uuid": "^10.0.0", - "eslint-config-next": "16.0.10", + "@types/video.js": "^7.3.58", + "eslint": "^9.39.2", + "eslint-config-next": "^16.1.6", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", - "globals": "^16.5.0", - "husky": "^9.0.11", - "jest": "^30.0.4", - "jest-environment-jsdom": "^30.0.4", - "lint-staged": "^16.1.2", - "playwright": "^1.54.1", - "stylelint": "^16.2.1", + "globals": "^17.3.0", + "husky": "^9.1.7", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "lint-staged": "^16.2.7", + "playwright": "^1.58.1", + "stylelint": "^17.1.0", "stylelint-config-rational-order": "^0.1.2", - "stylelint-config-standard": "^36.0.0", - "stylelint-order": "^6.0.4", - "stylelint-scss": "^6.1.0", - "ts-jest": "^29.4.0", + "stylelint-config-standard": "^40.0.0", + "stylelint-order": "^7.0.1", + "stylelint-scss": "^7.0.0", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.54.0" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/WebAdmin/src/app/api/health/route.ts b/WebAdmin/src/app/api/health/route.ts index 467919783..de57f6a76 100644 --- a/WebAdmin/src/app/api/health/route.ts +++ b/WebAdmin/src/app/api/health/route.ts @@ -1,20 +1,15 @@ import { NextResponse } from 'next/server'; +/** + * Health check endpoint for WebAdmin. + * Returns minimal information to avoid exposing sensitive details. + * WebAdmin runs behind Clerk authentication, but this endpoint is intentionally + * simple to support external monitoring services. + */ export async function GET() { try { - return NextResponse.json({ - status: 'healthy', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - memory: process.memoryUsage().rss - }); + return NextResponse.json({ status: 'ok' }); } catch { - return NextResponse.json( - { - status: 'unhealthy', - timestamp: new Date().toISOString() - }, - { status: 500 } - ); + return NextResponse.json({ status: 'error' }, { status: 500 }); } } \ No newline at end of file diff --git a/WebAdmin/src/app/chat/components/ChatMessages.tsx b/WebAdmin/src/app/chat/components/ChatMessages.tsx index 1bfe5a774..c1bbe94be 100755 --- a/WebAdmin/src/app/chat/components/ChatMessages.tsx +++ b/WebAdmin/src/app/chat/components/ChatMessages.tsx @@ -1,13 +1,17 @@ -import { ScrollArea, Stack, Text, Group, Badge, Paper, Code, Collapse, ActionIcon, Alert, HoverCard, CopyButton, Tooltip, Button } from '@mantine/core'; -import { IconUser, IconRobot, IconClock, IconBolt, IconAlertCircle, IconNetwork, IconLock, IconSearch, IconAlertTriangle, IconChevronDown, IconChevronUp, IconInfoCircle, IconCopy, IconCheck, IconCode, IconEye, IconTool, IconCircleCheck, IconCircleX, IconLoader, IconRefresh } from '@tabler/icons-react'; -import { ChatMessage, ChatErrorType } from '../types'; -import React, { useEffect, useRef, useState } from 'react'; +import { ScrollArea, Stack, Text, Group, Badge, Paper, Code, Collapse, ActionIcon, HoverCard, CopyButton, Tooltip } from '@mantine/core'; +import { IconUser, IconRobot, IconClock, IconBolt, IconChevronDown, IconChevronUp, IconInfoCircle, IconCopy, IconCheck, IconCode, IconEye } from '@tabler/icons-react'; +import { ChatMessage } from '../types'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'; import { ImagePreview } from './ImagePreview'; -import { processStructuredContent, getBlockQuoteMetadata, cleanBlockQuoteContent } from '@knn_labs/conduit-gateway-client'; +import { processStructuredContent } from '@knn_labs/conduit-gateway-client'; +import { MessageErrorCard } from './MessageErrorCard'; +import { ToolExecutionDisplay } from './ToolExecutionDisplay'; +import { CollapsibleThinking } from './CollapsibleThinking'; +import { streamingMarkdownComponents, createMessageMarkdownComponents } from '../utils/markdown'; interface ChatMessagesProps { messages: ChatMessage[]; @@ -19,37 +23,6 @@ interface ChatMessagesProps { onRetryMessage?: (messageId: string) => void; } -// Helper function to get error type styling -function getErrorTypeConfig(type: ChatErrorType) { - switch (type) { - case 'rate_limit': - return { icon: IconClock, color: 'orange', label: 'Rate Limit' }; - case 'model_not_found': - return { icon: IconSearch, color: 'blue', label: 'Model Not Found' }; - case 'auth_error': - return { icon: IconLock, color: 'red', label: 'Authentication Error' }; - case 'network_error': - return { icon: IconNetwork, color: 'gray', label: 'Network Error' }; - case 'server_error': - default: - return { icon: IconAlertTriangle, color: 'red', label: 'Server Error' }; - } -} - -// Helper function to get execution status color -function getExecutionStatusColor(isFailed: boolean, isCompleted: boolean): string { - if (isFailed) return 'red'; - if (isCompleted) return 'green'; - return 'blue'; -} - -// Helper function to get execution status background color -function getExecutionStatusBgColor(isFailed: boolean, isCompleted: boolean): string { - if (isFailed) return 'var(--mantine-color-red-light)'; - if (isCompleted) return 'var(--mantine-color-green-light)'; - return 'var(--mantine-color-blue-light)'; -} - export function ChatMessages({ messages, isLoading, streamingContent, streamingChannel, tokensPerSecond, reasoningExpanded = true, onRetryMessage }: ChatMessagesProps) { const scrollAreaRef = useRef(null); const lastMessageRef = useRef(null); @@ -57,199 +30,54 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC const [expandedReasoning, setExpandedReasoning] = useState>(new Set()); const [rawViewMessages, setRawViewMessages] = useState>(new Set()); + const messageMarkdownComponents = useMemo( + () => createMessageMarkdownComponents(CollapsibleThinking), + [] + ); + useEffect(() => { if (lastMessageRef.current) { lastMessageRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages, streamingContent]); - const toggleErrorDetails = (messageId: string) => { - setExpandedErrors(prev => { + const toggleSet = (setter: React.Dispatch>>, id: string) => { + setter(prev => { const next = new Set(prev); - if (next.has(messageId)) { - next.delete(messageId); + if (next.has(id)) { + next.delete(id); } else { - next.add(messageId); + next.add(id); } return next; }); }; - const toggleRawView = (messageId: string) => { - setRawViewMessages(prev => { - const next = new Set(prev); - if (next.has(messageId)) { - next.delete(messageId); - } else { - next.add(messageId); - } - return next; - }); - }; - - // Component for collapsible thinking blocks - const CollapsibleThinking = ({ content, icon, title }: { content: string; icon: string; title: string }) => { - const [isOpen, setIsOpen] = useState(false); - - return ( - setIsOpen(!isOpen)} - > - - - {isOpen ? : } - - - {icon} {title} - - - {isOpen ? 'Click to collapse' : 'Click to expand'} - - - -
- {content} -
-
-
- ); - }; + const isReasoningExpanded = (messageId: string) => + expandedReasoning.has(messageId) ? !reasoningExpanded : reasoningExpanded; const renderMessage = (message: ChatMessage, isStreaming = false) => { const isUser = message.role === 'user'; - const content = message.content; // Just use the content from the message + const content = message.content; const hasError = message.error && !isUser; - const errorConfig = hasError && message.error ? getErrorTypeConfig(message.error.type) : null; - const isExpanded = expandedErrors.has(message.id); const isRawView = rawViewMessages.has(message.id); - - // Check if this message has reasoning in metadata const hasReasoning = !isUser && message.metadata?.hasReasoning && message.metadata?.reasoning; const reasoningText = hasReasoning ? message.metadata?.reasoning : null; - // For error messages, render special error UI - if (hasError && errorConfig && message.error) { - const Icon = errorConfig.icon; + // Error messages get a dedicated card + if (hasError && message.error) { return ( - - - {/* Error header with icon and type */} - - - - - {errorConfig.label} - - - {message.error.retryAfter && ( - - Retry after {message.error.retryAfter}s - - )} - - - {/* User-friendly error message */} - - {content?.replace('Error: ', '')} - - - {/* Suggestions if available */} - {message.error.suggestions && message.error.suggestions.length > 0 && ( - } color={errorConfig.color} variant="light"> - - Suggestions: - {message.error.suggestions.map((suggestion) => ( - โ€ข {suggestion} - ))} - - - )} - - {/* Technical details (expandable) */} - {(message.error.technical ?? message.error.code ?? message.error.statusCode) && ( - <> - - toggleErrorDetails(message.id)} - > - {isExpanded ? : } - - Technical Details - - - - - {message.error.statusCode && ( - - HTTP Status: {message.error.statusCode} - - )} - {message.error.code && ( - - Error Code: {message.error.code} - - )} - {message.error.technical && ( - - {message.error.technical} - - )} - - - - - )} - - {/* Retry button for recoverable errors */} - {message.error.recoverable && onRetryMessage && ( - - - - )} - - + message={message} + isExpanded={expandedErrors.has(message.id)} + onToggleDetails={() => toggleSet(setExpandedErrors, message.id)} + onRetry={onRetryMessage ? () => onRetryMessage(message.id) : undefined} + isLoading={isLoading} + /> ); } - // Regular message rendering return ( + {/* Message header */} - {isUser ? ( - - ) : ( - - )} + {isUser ? : } {isUser ? 'You' : message.model ?? 'Assistant'} - {/* Function indicators for user messages */} {isUser && message.metadata?.functionNames && message.metadata.functionNames.length > 0 && ( {message.metadata.functionNames.map((name: string) => ( @@ -285,32 +109,28 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC )} - {/* User message metadata (code icon) */} + {/* Raw view toggle + metadata badges */} {isUser && ( - - {/* Toggle Raw View Button for user messages */} - - toggleRawView(message.id)} - color={isRawView ? 'blue' : 'gray'} - > - {isRawView ? : } - - - + + toggleSet(setRawViewMessages, message.id)} + color={isRawView ? 'blue' : 'gray'} + > + {isRawView ? : } + + )} {!isUser && (message.metadata ?? (isStreaming && tokensPerSecond)) && ( - {/* Toggle Raw View Button */} {message.metadata && !isStreaming && ( - toggleRawView(message.id)} + toggleSet(setRawViewMessages, message.id)} color={isRawView ? 'blue' : 'gray'} > {isRawView ? : } @@ -341,7 +161,6 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC )} - {/* Metadata hover card */} {(message.metadata?.provider ?? message.metadata?.model ?? message.metadata?.promptTokens ?? message.metadata?.completionTokens) && ( @@ -389,11 +208,13 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC )} - + + {/* Images */} {message.images && message.images.length > 0 && ( )} - + + {/* Function calls */} {message.functionCall && ( Function Call: @@ -403,7 +224,8 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC )} - + + {/* Tool calls */} {message.toolCalls && message.toolCalls.length > 0 && ( Tool Calls: @@ -418,79 +240,18 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC )} - {/* Tool Execution Progress */} + {/* Tool execution progress */} {message.metadata?.toolExecutions && message.metadata.toolExecutions.length > 0 && ( - - - - Tool Execution: - - {message.metadata.toolExecutions.map((execution, idx) => { - const isStarted = execution.status === 'started'; - const isCompleted = execution.status === 'completed'; - const isFailed = execution.status === 'failed'; - - return ( - - - - {isStarted && } - {isCompleted && } - {isFailed && } - {execution.function_name} - - - {execution.status} - - - - {execution.error_message && ( - - Error: {execution.error_message} - - )} - - {execution.result !== undefined && ( - - {(() => { - const result = execution.result as unknown; - return typeof result === 'string' - ? result - : JSON.stringify(result, null, 2); - })()} - - )} - - {execution.cost !== undefined && execution.cost > 0 && ( - - Cost: ${execution.cost.toFixed(4)} - - )} - - ); - })} - + )} - {/* Show reasoning if present - collapsible (only in normal view) */} + {/* Reasoning block (collapsible) */} {reasoningText && !isRawView && ( - { - const newExpanded = new Set(expandedReasoning); - if (newExpanded.has(message.id)) { - newExpanded.delete(message.id); - } else { - newExpanded.add(message.id); - } - setExpandedReasoning(newExpanded); - }} + onClick={() => toggleSet(setExpandedReasoning, message.id)} > - - - {/* Check if this message's reasoning is expanded */} - {(() => { - const isExpanded = expandedReasoning.has(message.id) - ? !reasoningExpanded // If in set, opposite of default - : reasoningExpanded; // Otherwise, use default - return isExpanded ? : ; - })()} + + + {isReasoningExpanded(message.id) ? : } - ๐Ÿง  Reasoning + {'\uD83E\uDDE0'} Reasoning - {(() => { - const isExpanded = expandedReasoning.has(message.id) - ? !reasoningExpanded // If in set, opposite of default - : reasoningExpanded; // Otherwise, use default - return isExpanded ? 'Click to collapse' : 'Click to expand'; - })()} + {isReasoningExpanded(message.id) ? 'Click to collapse' : 'Click to expand'} - { - const isExpanded = expandedReasoning.has(message.id) - ? !reasoningExpanded // If in set, opposite of default - : reasoningExpanded; // Otherwise, use default - return isExpanded; - })()}> +
{reasoningText}
)} - - {/* Conditionally render normal view or raw JSON view */} + + {/* Message content: raw JSON or formatted markdown */} {(() => { - // Raw view for user messages if (isRawView && !isStreaming && isUser) { return ( - {/* Message Data Section */}
Message Data:
- + {JSON.stringify({ - id: message.id, - role: message.role, - timestamp: message.timestamp, - content: message.content, + id: message.id, role: message.role, timestamp: message.timestamp, content: message.content, ...(message.images && message.images.length > 0 && { images: message.images }), ...(message.metadata?.functionIds && { function_ids: message.metadata.functionIds }), ...(message.metadata?.functionNames && { function_names: message.metadata.functionNames }) @@ -581,21 +298,11 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC
- - {/* API Request Section */} {message.metadata?.apiRequest && (
API Request Sent to Conduit:
- + {JSON.stringify(message.metadata.apiRequest, null, 2)}
@@ -605,24 +312,13 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC ); } - // Raw view for assistant messages if (isRawView && !isStreaming && !isUser) { return (
- + {JSON.stringify({ - id: message.id, - timestamp: message.timestamp, - model: message.model ?? message.metadata?.model, - content: message.content, + id: message.id, timestamp: message.timestamp, + model: message.model ?? message.metadata?.model, content: message.content, metadata: message.metadata ? { ...(message.metadata.latency !== undefined && { latency_ms: message.metadata.latency }), ...(message.metadata.timeToFirstToken !== undefined && { time_to_first_token_ms: message.metadata.timeToFirstToken }), @@ -646,170 +342,40 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC ); } - // Normal Markdown View + // Normal markdown view return ( -
- {/* JUST SHOW THE RAW CONTENT */} - {isStreaming ? ( -
{content}
- ) : ( - { - if (typeof node === 'string') return node; - if (typeof node === 'number') return node.toString(); - if (Array.isArray(node)) return node.map(getChildrenText).join(''); - return ''; - }; - - const childText = getChildrenText(children); - - return !inline && match ? ( - )} - > - {childText.replace(/\n$/, '')} - - ) : ( - - {childText} - - ); - }, - blockquote({ children, ...props }) { - const getChildrenText = (node: React.ReactNode): string => { - if (typeof node === 'string') return node; - if (typeof node === 'number') return node.toString(); - if (Array.isArray(node)) return node.map(getChildrenText).join(''); - if (!node || typeof node !== 'object') return ''; - - // Type guard for React element - if (React.isValidElement(node)) { - const element = node as React.ReactElement<{children?: React.ReactNode}>; - if (element.props?.children !== undefined) { - return getChildrenText(element.props.children); - } - } - return ''; - }; - - const text = getChildrenText(children); - const metadata = getBlockQuoteMetadata(text); - - // Handle thinking blocks with collapsible UI - if (metadata.type === 'thinking') { - const cleanedContent = cleanBlockQuoteContent(text); - - return ( - - ); - } - - // Handle warning blocks - if (metadata.type === 'warning') { - const cleanedContent = cleanBlockQuoteContent(text); - - return ( - } - color="orange" - variant="light" - radius="md" - > - {cleanedContent} - - ); - } - - // Handle summary blocks - if (metadata.type === 'summary') { - const cleanedContent = cleanBlockQuoteContent(text); - - return ( - - - {metadata.icon} {metadata.title} - - {cleanedContent} - - ); - } - - // Default blockquote - return
{children}
; - }, - }} - > - {processStructuredContent(content ?? '')} -
- )} -
+
+ {isStreaming ? ( +
{content}
+ ) : ( + + {processStructuredContent(content ?? '')} + + )} +
); })()} - + {/* Copy button */} {content && ( { - if (!isRawView) { - return content; - } + if (!isRawView) return content; if (isUser) { return JSON.stringify({ - message_data: { - id: message.id, - role: message.role, - timestamp: message.timestamp, - content: message.content, - images: message.images, - function_ids: message.metadata?.functionIds, - function_names: message.metadata?.functionNames - }, + message_data: { id: message.id, role: message.role, timestamp: message.timestamp, content: message.content, images: message.images, function_ids: message.metadata?.functionIds, function_names: message.metadata?.functionNames }, api_request: message.metadata?.apiRequest }, null, 2); } return JSON.stringify({ - id: message.id, - timestamp: message.timestamp, - model: message.model ?? message.metadata?.model, - content: message.content, - metadata: message.metadata, - function_call: message.functionCall, - tool_calls: message.toolCalls, - images: message.images + id: message.id, timestamp: message.timestamp, model: message.model ?? message.metadata?.model, + content: message.content, metadata: message.metadata, function_call: message.functionCall, + tool_calls: message.toolCalls, images: message.images }, null, 2); })()} timeout={2000}> {({ copied, copy }) => ( - { - if (copied) return 'Copied!'; - return isRawView ? 'Copy JSON' : 'Copy message'; - })()} withArrow position="left"> - + + {copied ? : } @@ -823,8 +389,8 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC }; return ( - @@ -852,39 +415,7 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC {streamingContent ? (
- { - if (typeof node === 'string') return node; - if (typeof node === 'number') return node.toString(); - if (Array.isArray(node)) return node.map(getChildrenText).join(''); - return ''; - }; - - const childText = getChildrenText(children); - - return !inline && match ? ( - )} - > - {childText.replace(/\n$/, '')} - - ) : ( - - {childText} - - ); - }, - }} - > + {streamingContent}
@@ -904,4 +435,4 @@ export function ChatMessages({ messages, isLoading, streamingContent, streamingC
); -} \ No newline at end of file +} diff --git a/WebAdmin/src/app/chat/components/ChatStreamingLogic.ts b/WebAdmin/src/app/chat/components/ChatStreamingLogic.ts index eed15d3b9..a6af33b7f 100644 --- a/WebAdmin/src/app/chat/components/ChatStreamingLogic.ts +++ b/WebAdmin/src/app/chat/components/ChatStreamingLogic.ts @@ -14,6 +14,9 @@ import { ChatMessage, ChatErrorType } from '../types'; +// Needs raw notifications API: .show is passed as callback to SDK's createToastErrorHandler, +// .hide is used for dismissing retry notifications, and custom options (id, loading, autoClose, +// withCloseButton) are used for retry notifications that notify doesn't support. import { notifications } from '@mantine/notifications'; interface ChatStreamingLogicParams { diff --git a/WebAdmin/src/app/chat/components/CodeBlock.tsx b/WebAdmin/src/app/chat/components/CodeBlock.tsx deleted file mode 100755 index ccbb724aa..000000000 --- a/WebAdmin/src/app/chat/components/CodeBlock.tsx +++ /dev/null @@ -1,244 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { - Paper, - Group, - ActionIcon, - CopyButton, - Tooltip, - Text, - ScrollArea, - Box -} from '@mantine/core'; -import { IconCheck, IconCopy, IconTerminal2 } from '@tabler/icons-react'; - -interface CodeBlockProps { - code: string; - language?: string; - filename?: string; -} - -// Basic syntax highlighting patterns -const SYNTAX_PATTERNS: Record> = { - javascript: [ - { pattern: /\b(const|let|var|function|return|if|else|for|while|class|import|export|from|async|await)\b/g, className: 'keyword' }, - { pattern: /(["'`])(?:(?=(\\?))\2.)*?\1/g, className: 'string' }, - { pattern: /\/\/.*$/gm, className: 'comment' }, - { pattern: /\/\*[\s\S]*?\*\//g, className: 'comment' }, - { pattern: /\b(\d+)\b/g, className: 'number' }, - ], - typescript: [ - { pattern: /\b(const|let|var|function|return|if|else|for|while|class|import|export|from|async|await|type|interface|enum)\b/g, className: 'keyword' }, - { pattern: /(["'`])(?:(?=(\\?))\2.)*?\1/g, className: 'string' }, - { pattern: /\/\/.*$/gm, className: 'comment' }, - { pattern: /\/\*[\s\S]*?\*\//g, className: 'comment' }, - { pattern: /\b(\d+)\b/g, className: 'number' }, - { pattern: /\b(string|number|boolean|any|void|never|unknown)\b/g, className: 'type' }, - ], - python: [ - { pattern: /\b(def|class|import|from|return|if|else|elif|for|while|with|as|try|except|finally|pass|break|continue|async|await)\b/g, className: 'keyword' }, - { pattern: /(["'])(?:(?=(\\?))\2.)*?\1/g, className: 'string' }, - { pattern: /#.*$/gm, className: 'comment' }, - { pattern: /\b(\d+)\b/g, className: 'number' }, - { pattern: /\b(True|False|None)\b/g, className: 'constant' }, - ], - json: [ - { pattern: /(["'])(?:(?=(\\?))\2.)*?\1(?=\s*:)/g, className: 'property' }, - { pattern: /(["'])(?:(?=(\\?))\2.)*?\1/g, className: 'string' }, - { pattern: /\b(\d+)\b/g, className: 'number' }, - { pattern: /\b(true|false|null)\b/g, className: 'constant' }, - ], -}; - -function highlightCode(code: string, language?: string): string { - if (!language || !SYNTAX_PATTERNS[language]) { - return escapeHtml(code); - } - - let highlighted = escapeHtml(code); - const patterns = SYNTAX_PATTERNS[language]; - - // Apply syntax highlighting - patterns.forEach(({ pattern, className }) => { - highlighted = highlighted.replace(pattern, (match) => { - return `${match}`; - }); - }); - - return highlighted; -} - -function escapeHtml(text: string): string { - const map: Record = { - ['&']: '&', - ['<']: '<', - ['>']: '>', - ['"']: '"', - ["'"]: ''', - }; - return text.replace(/[&<>"']/g, (m) => map[m]); -} - -export function CodeBlock({ code, language, filename }: CodeBlockProps) { - const [isExpanded, setIsExpanded] = useState(false); - const lines = code.split('\n'); - const shouldTruncate = lines.length > 20; - const displayCode = shouldTruncate && !isExpanded ? lines.slice(0, 20).join('\n') : code; - const highlightedCode = highlightCode(displayCode, language); - - return ( - - - - - {filename && {filename}} - {language && !filename && {language}} - - - {({ copied, copy }) => ( - - - {copied ? : } - - - )} - - - - - -
-            
-          
-
-
- - {shouldTruncate && ( - - setIsExpanded(!isExpanded)} - > - {isExpanded ? 'Show less' : `Show ${lines.length - 20} more lines`} - - - )} - - -
- ); -} \ No newline at end of file diff --git a/WebAdmin/src/app/chat/components/CollapsibleThinking.tsx b/WebAdmin/src/app/chat/components/CollapsibleThinking.tsx new file mode 100644 index 000000000..e50f03603 --- /dev/null +++ b/WebAdmin/src/app/chat/components/CollapsibleThinking.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { Paper, Group, ActionIcon, Text, Collapse } from '@mantine/core'; +import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface CollapsibleThinkingProps { + content: string; + icon: string; + title: string; +} + +export function CollapsibleThinking({ content, icon, title }: CollapsibleThinkingProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(!isOpen)} + > + + + {isOpen ? : } + + + {icon} {title} + + + {isOpen ? 'Click to collapse' : 'Click to expand'} + + + +
+ {content} +
+
+
+ ); +} diff --git a/WebAdmin/src/app/chat/components/ImageUpload.tsx b/WebAdmin/src/app/chat/components/ImageUpload.tsx index 824cad3b9..4ce687db3 100755 --- a/WebAdmin/src/app/chat/components/ImageUpload.tsx +++ b/WebAdmin/src/app/chat/components/ImageUpload.tsx @@ -13,7 +13,7 @@ import { Tooltip } from '@mantine/core'; import { IconPhoto } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { ImageAttachment } from '../types'; interface ImageUploadProps { @@ -47,31 +47,19 @@ export function ImageUpload({ // Check if we've reached max images if (images.length + newImages.length >= maxImages) { - notifications.show({ - title: 'Max images reached', - message: `You can only upload up to ${maxImages} images`, - color: 'yellow', - }); + notify.warning(`You can only upload up to ${maxImages} images`, 'Max images reached'); break; } // Validate file type if (!file.type.startsWith('image/')) { - notifications.show({ - title: 'Invalid file type', - message: `${file.name} is not an image`, - color: 'red', - }); + notify.error(`${file.name} is not an image`); continue; } // Check file size if (file.size > maxSizeInBytes) { - notifications.show({ - title: 'File too large', - message: `${file.name} exceeds ${maxSizeInMB}MB limit`, - color: 'red', - }); + notify.error(`${file.name} exceeds ${maxSizeInMB}MB limit`); continue; } @@ -91,11 +79,7 @@ export function ImageUpload({ }); } catch (error) { console.error('Error processing image:', error); - notifications.show({ - title: 'Error processing image', - message: `Failed to process ${file.name}`, - color: 'red', - }); + notify.error(`Failed to process ${file.name}`); } } diff --git a/WebAdmin/src/app/chat/components/MarkdownRenderer.tsx b/WebAdmin/src/app/chat/components/MarkdownRenderer.tsx deleted file mode 100755 index c4b8f7f3d..000000000 --- a/WebAdmin/src/app/chat/components/MarkdownRenderer.tsx +++ /dev/null @@ -1,239 +0,0 @@ -'use client'; - -import React from 'react'; -import { Text, Table, Anchor, List, Title, Divider, Blockquote } from '@mantine/core'; -import { CodeBlock } from './CodeBlock'; - -interface MarkdownRendererProps { - content: string; -} - -// Simple markdown parser -function parseMarkdown(text: string): React.ReactNode[] { - const elements: React.ReactNode[] = []; - const lines = text.split('\n'); - let i = 0; - let key = 0; - - while (i < lines.length) { - const line = lines[i]; - - // Code blocks - if (line.startsWith('```')) { - const language = line.slice(3).trim(); - const codeLines: string[] = []; - i++; - while (i < lines.length && !lines[i].startsWith('```')) { - codeLines.push(lines[i]); - i++; - } - elements.push( - - ); - i++; - continue; - } - - // Headers - const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); - if (headerMatch) { - const level = headerMatch[1].length; - elements.push( - - {parseInline(headerMatch[2])} - - ); - i++; - continue; - } - - // Horizontal rule - if (line.match(/^---+$/)) { - elements.push(); - i++; - continue; - } - - // Blockquote - if (line.startsWith('>')) { - const quoteLines: string[] = []; - while (i < lines.length && lines[i].startsWith('>')) { - quoteLines.push(lines[i].slice(1).trim()); - i++; - } - elements.push( -
- {quoteLines.map((l) => ( - {parseInline(l)} - ))} -
- ); - continue; - } - - // Lists - if (line.match(/^[*\-+]\s+/) || line.match(/^\d+\.\s+/)) { - const listItems: string[] = []; - const isOrdered = /^\d+\.\s+/.test(line); - - while (i < lines.length && (lines[i].match(/^[*\-+]\s+/) || lines[i].match(/^\d+\.\s+/))) { - listItems.push(lines[i].replace(/^[*\-+]\s+/, '').replace(/^\d+\.\s+/, '')); - i++; - } - - elements.push( - - {listItems.map((item) => ( - {parseInline(item)} - ))} - - ); - continue; - } - - // Tables (simple implementation) - if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) { - const headers = line.split('|').map(h => h.trim()).filter(Boolean); - i += 2; // Skip separator line - const rows: string[][] = []; - - while (i < lines.length && lines[i].includes('|')) { - rows.push(lines[i].split('|').map(c => c.trim()).filter(Boolean)); - i++; - } - - elements.push( - - - - {headers.map((h) => ( - {parseInline(h)} - ))} - - - - {rows.map((row) => ( - - {row.map((cell) => ( - {parseInline(cell)} - ))} - - ))} - -
- ); - continue; - } - - // Regular paragraphs - if (line.trim()) { - elements.push( - - {parseInline(line)} - - ); - } - - i++; - } - - return elements; -} - -// Parse inline markdown elements -function parseInline(text: string): React.ReactNode { - const elements: React.ReactNode[] = []; - let lastIndex = 0; - - // Combined regex for all inline patterns - const patterns = [ - { regex: /\*\*([^*]+)\*\*/g, render: (m: RegExpExecArray) => {m[1]} }, - { regex: /\*([^*]+)\*/g, render: (m: RegExpExecArray) => {m[1]} }, - { regex: /`([^`]+)`/g, render: (m: RegExpExecArray) => {m[1]} }, - { regex: /\[([^\]]+)\]\(([^)]+)\)/g, render: (m: RegExpExecArray) => ( - - {m[1]} - - )}, - // Basic math support (inline) - { regex: /\$([^$]+)\$/g, render: (m: RegExpExecArray) => ( - - {m[1]} - - )}, - ]; - - // Find all matches - const matches: Array<{ index: number; length: number; element: React.ReactNode }> = []; - - patterns.forEach(({ regex, render }) => { - let match; - regex.lastIndex = 0; - while ((match = regex.exec(text)) !== null) { - matches.push({ - index: match.index, - length: match[0].length, - element: render(match), - }); - } - }); - - // Sort matches by index - matches.sort((a, b) => a.index - b.index); - - // Build result - matches.forEach((match) => { - if (match.index > lastIndex) { - elements.push(text.substring(lastIndex, match.index)); - } - elements.push(match.element); - lastIndex = match.index + match.length; - }); - - if (lastIndex < text.length) { - elements.push(text.substring(lastIndex)); - } - - if (elements.length > 1) { - return elements; - } - if (elements.length === 1) { - return elements[0]; - } - return text; -} - -export function MarkdownRenderer({ content }: MarkdownRendererProps) { - const elements = parseMarkdown(content); - - return ( -
- {elements} - -
- ); -} \ No newline at end of file diff --git a/WebAdmin/src/app/chat/components/MessageErrorCard.tsx b/WebAdmin/src/app/chat/components/MessageErrorCard.tsx new file mode 100644 index 000000000..755c558ad --- /dev/null +++ b/WebAdmin/src/app/chat/components/MessageErrorCard.tsx @@ -0,0 +1,127 @@ +import { Paper, Stack, Group, Badge, Text, Alert, ActionIcon, Collapse, Code, Button } from '@mantine/core'; +import { IconClock, IconSearch, IconLock, IconNetwork, IconAlertTriangle, IconAlertCircle, IconChevronDown, IconChevronUp, IconRefresh } from '@tabler/icons-react'; +import type { ChatMessage, ChatErrorType } from '../types'; + +function getErrorTypeConfig(type: ChatErrorType) { + switch (type) { + case 'rate_limit': + return { icon: IconClock, color: 'orange', label: 'Rate Limit' }; + case 'model_not_found': + return { icon: IconSearch, color: 'blue', label: 'Model Not Found' }; + case 'auth_error': + return { icon: IconLock, color: 'red', label: 'Authentication Error' }; + case 'network_error': + return { icon: IconNetwork, color: 'gray', label: 'Network Error' }; + case 'server_error': + default: + return { icon: IconAlertTriangle, color: 'red', label: 'Server Error' }; + } +} + +interface MessageErrorCardProps { + message: ChatMessage; + isExpanded: boolean; + onToggleDetails: () => void; + onRetry?: () => void; + isLoading?: boolean; +} + +export function MessageErrorCard({ message, isExpanded, onToggleDetails, onRetry, isLoading }: MessageErrorCardProps) { + const error = message.error; + if (!error) return null; + + const errorConfig = getErrorTypeConfig(error.type); + const Icon = errorConfig.icon; + const content = message.content; + + return ( + + + + + + + {errorConfig.label} + + + {error.retryAfter && ( + + Retry after {error.retryAfter}s + + )} + + + + {content?.replace('Error: ', '')} + + + {error.suggestions && error.suggestions.length > 0 && ( + } color={errorConfig.color} variant="light"> + + Suggestions: + {error.suggestions.map((suggestion) => ( + {'\u2022'} {suggestion} + ))} + + + )} + + {(error.technical ?? error.code ?? error.statusCode) && ( + <> + + + {isExpanded ? : } + + Technical Details + + + + + {error.statusCode && ( + + HTTP Status: {error.statusCode} + + )} + {error.code && ( + + Error Code: {error.code} + + )} + {error.technical && ( + + {error.technical} + + )} + + + + + )} + + {error.recoverable && onRetry && ( + + + + )} + + + ); +} diff --git a/WebAdmin/src/app/chat/components/ToolExecutionDisplay.tsx b/WebAdmin/src/app/chat/components/ToolExecutionDisplay.tsx new file mode 100644 index 000000000..1ee83ead3 --- /dev/null +++ b/WebAdmin/src/app/chat/components/ToolExecutionDisplay.tsx @@ -0,0 +1,93 @@ +import { Stack, Group, Paper, Text, Badge, Code } from '@mantine/core'; +import { IconTool, IconLoader, IconCircleCheck, IconCircleX } from '@tabler/icons-react'; + +interface ToolExecution { + tool_call_id?: string; + function_name: string; + status: string; + result?: unknown; + error_message?: string; + cost?: number; +} + +function getStatusColor(isFailed: boolean, isCompleted: boolean): string { + if (isFailed) return 'red'; + if (isCompleted) return 'green'; + return 'blue'; +} + +function getStatusBgColor(isFailed: boolean, isCompleted: boolean): string { + if (isFailed) return 'var(--mantine-color-red-light)'; + if (isCompleted) return 'var(--mantine-color-green-light)'; + return 'var(--mantine-color-blue-light)'; +} + +interface ToolExecutionDisplayProps { + executions: ToolExecution[]; +} + +export function ToolExecutionDisplay({ executions }: ToolExecutionDisplayProps) { + if (executions.length === 0) return null; + + return ( + + + + Tool Execution: + + {executions.map((execution, idx) => { + const isStarted = execution.status === 'started'; + const isCompleted = execution.status === 'completed'; + const isFailed = execution.status === 'failed'; + + return ( + + + + {isStarted && } + {isCompleted && } + {isFailed && } + {execution.function_name} + + + {execution.status} + + + + {execution.error_message && ( + + Error: {execution.error_message} + + )} + + {execution.result !== undefined && ( + + {typeof execution.result === 'string' + ? execution.result + : JSON.stringify(execution.result, null, 2)} + + )} + + {execution.cost !== undefined && execution.cost > 0 && ( + + Cost: ${execution.cost.toFixed(4)} + + )} + + ); + })} + + ); +} diff --git a/WebAdmin/src/app/chat/utils/markdown.tsx b/WebAdmin/src/app/chat/utils/markdown.tsx new file mode 100644 index 000000000..5effd1c78 --- /dev/null +++ b/WebAdmin/src/app/chat/utils/markdown.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { Paper, Text, Alert } from '@mantine/core'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { getBlockQuoteMetadata, cleanBlockQuoteContent } from '@knn_labs/conduit-gateway-client'; + +/** + * Extract plain text from React children nodes. + * Used by markdown renderers to get raw text content from nested elements. + */ +export function getChildrenText(node: React.ReactNode): string { + if (typeof node === 'string') return node; + if (typeof node === 'number') return node.toString(); + if (Array.isArray(node)) return node.map(getChildrenText).join(''); + if (!node || typeof node !== 'object') return ''; + + if (React.isValidElement(node)) { + const element = node as React.ReactElement<{ children?: React.ReactNode }>; + if (element.props?.children !== undefined) { + return getChildrenText(element.props.children); + } + } + return ''; +} + +/** + * Shared code block renderer for react-markdown. + * Handles both inline code and fenced code blocks with syntax highlighting. + */ +function CodeRenderer({ className, children, ...props }: { className?: string; children?: React.ReactNode; [key: string]: unknown }) { + const match = /language-(\w+)/.exec(className ?? ''); + const inline = !className; + const childText = getChildrenText(children); + + return !inline && match ? ( + )} + > + {childText.replace(/\n$/, '')} + + ) : ( + + {childText} + + ); +} + +/** + * Minimal markdown components for streaming content (code blocks only). + */ +export const streamingMarkdownComponents: Components = { + code: CodeRenderer as Components['code'], +}; + +/** + * Creates full markdown components including blockquote handling for thinking/warning/summary blocks. + */ +export function createMessageMarkdownComponents(CollapsibleThinkingComponent: React.ComponentType<{ content: string; icon: string; title: string }>): Components { + return { + code: CodeRenderer as Components['code'], + blockquote({ children, ...props }) { + const text = getChildrenText(children); + const metadata = getBlockQuoteMetadata(text); + + if (metadata.type === 'thinking') { + const cleanedContent = cleanBlockQuoteContent(text); + return ( + + ); + } + + if (metadata.type === 'warning') { + const cleanedContent = cleanBlockQuoteContent(text); + return ( + } + color="orange" + variant="light" + radius="md" + > + {cleanedContent} + + ); + } + + if (metadata.type === 'summary') { + const cleanedContent = cleanBlockQuoteContent(text); + return ( + + + {metadata.icon} {metadata.title} + + {cleanedContent} + + ); + } + + return
{children}
; + }, + }; +} diff --git a/WebAdmin/src/app/cost-dashboard/handlers.ts b/WebAdmin/src/app/cost-dashboard/handlers.ts index 62ff14737..ea8cee2ec 100644 --- a/WebAdmin/src/app/cost-dashboard/handlers.ts +++ b/WebAdmin/src/app/cost-dashboard/handlers.ts @@ -1,4 +1,4 @@ -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { withAdminClient } from '@/lib/client/adminClient'; import { safeLog } from '@/lib/utils/logging'; import type { DateRange } from './types'; @@ -11,18 +11,10 @@ export function useCostDashboardHandlers( const handleRefresh = async () => { try { await refetchAll(); - notifications.show({ - title: 'Data Refreshed', - message: 'Cost data has been updated', - color: 'green', - }); + notify.success('Cost data has been updated', 'Data Refreshed'); } catch (err) { safeLog('error', 'Failed to refresh cost data', err); - notifications.show({ - title: 'Refresh Failed', - message: 'Failed to refresh cost data', - color: 'red', - }); + notify.error(err, 'Failed to refresh cost data'); } }; @@ -73,18 +65,10 @@ export function useCostDashboardHandlers( document.body.removeChild(a); URL.revokeObjectURL(url); - notifications.show({ - title: 'Export Successful', - message: 'Cost report has been downloaded', - color: 'green', - }); + notify.success('Cost report has been downloaded', 'Export Successful'); } catch (err) { safeLog('error', 'Failed to export cost data', err); - notifications.show({ - title: 'Export Failed', - message: 'Failed to export cost data', - color: 'red', - }); + notify.error(err, 'Failed to export cost data'); } finally { setIsExporting(false); } diff --git a/WebAdmin/src/app/functions/configurations/page.tsx b/WebAdmin/src/app/functions/configurations/page.tsx index ff27b38a7..5a1744048 100644 --- a/WebAdmin/src/app/functions/configurations/page.tsx +++ b/WebAdmin/src/app/functions/configurations/page.tsx @@ -31,7 +31,7 @@ import { IconDots, IconTestPipe, } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { useAdminClient } from '@/lib/client/adminClient'; import { FunctionConfigurationDto, @@ -80,11 +80,7 @@ export default function FunctionConfigurationsPage() { setConfigurations(response); } catch (err) { console.warn('Error loading configurations:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to load configurations', - color: 'red', - }); + notify.error(err, 'Failed to load configurations'); } finally { setLoading(false); } @@ -99,11 +95,7 @@ export default function FunctionConfigurationsPage() { await executeWithAdmin(client => client.functionConfigurations.create(formData) ); - notifications.show({ - title: 'Success', - message: 'Configuration created successfully', - color: 'green', - }); + notify.success('Configuration created successfully'); setShowModal(false); resetForm(); @@ -114,11 +106,7 @@ export default function FunctionConfigurationsPage() { await loadConfigurations(); } catch (err) { console.warn('Error creating configuration:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to create configuration', - color: 'red', - }); + notify.error(err, 'Failed to create configuration'); } }; @@ -140,22 +128,14 @@ export default function FunctionConfigurationsPage() { await executeWithAdmin(client => client.functionConfigurations.update(editingConfig.id, updateData) ); - notifications.show({ - title: 'Success', - message: 'Configuration updated successfully', - color: 'green', - }); + notify.success('Configuration updated successfully'); setShowModal(false); setEditingConfig(null); resetForm(); await loadConfigurations(); } catch (err) { console.warn('Error updating configuration:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to update configuration', - color: 'red', - }); + notify.error(err, 'Failed to update configuration'); } }; @@ -164,19 +144,11 @@ export default function FunctionConfigurationsPage() { await executeWithAdmin(client => client.functionConfigurations.deleteById(id) ); - notifications.show({ - title: 'Success', - message: 'Configuration deleted successfully', - color: 'green', - }); + notify.success('Configuration deleted successfully'); await loadConfigurations(); } catch (err) { console.warn('Error deleting configuration:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to delete configuration', - color: 'red', - }); + notify.error(err, 'Failed to delete configuration'); } }; @@ -188,19 +160,11 @@ export default function FunctionConfigurationsPage() { isEnabled: !config.isEnabled, }) ); - notifications.show({ - title: 'Success', - message: `Configuration ${config.isEnabled ? 'disabled' : 'enabled'}`, - color: 'green', - }); + notify.success(`Configuration ${config.isEnabled ? 'disabled' : 'enabled'}`); await loadConfigurations(); } catch (err) { console.warn('Error toggling configuration:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to toggle configuration', - color: 'red', - }); + notify.error(err, 'Failed to toggle configuration'); } }; diff --git a/WebAdmin/src/app/functions/costs/page.tsx b/WebAdmin/src/app/functions/costs/page.tsx index 80ead1592..b89c09782 100644 --- a/WebAdmin/src/app/functions/costs/page.tsx +++ b/WebAdmin/src/app/functions/costs/page.tsx @@ -31,7 +31,7 @@ import { IconDots, IconTrashX } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { modals } from '@mantine/modals'; import { useAdminClient } from '@/lib/client/adminClient'; import { @@ -127,11 +127,7 @@ export default function FunctionCostsPage() { setCosts(response); } catch (err) { console.warn('Error loading costs:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to load costs', - color: 'red', - }); + notify.error(err, 'Failed to load costs'); } finally { setLoading(false); } @@ -149,21 +145,13 @@ export default function FunctionCostsPage() { await executeWithAdmin(client => client.functionCosts.create(formData) ); - notifications.show({ - title: 'Success', - message: 'Cost configuration created successfully', - color: 'green', - }); + notify.success('Cost configuration created successfully'); setShowModal(false); resetForm(); await loadCosts(); } catch (err) { console.warn('Error creating cost:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to create cost', - color: 'red', - }); + notify.error(err, 'Failed to create cost'); } }; @@ -190,22 +178,14 @@ export default function FunctionCostsPage() { await executeWithAdmin(client => client.functionCosts.update(editingCost.id, updateData) ); - notifications.show({ - title: 'Success', - message: 'Cost configuration updated successfully', - color: 'green', - }); + notify.success('Cost configuration updated successfully'); setShowModal(false); setEditingCost(null); resetForm(); await loadCosts(); } catch (err) { console.warn('Error updating cost:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to update cost', - color: 'red', - }); + notify.error(err, 'Failed to update cost'); } }; @@ -225,19 +205,11 @@ export default function FunctionCostsPage() { await executeWithAdmin(client => client.functionCosts.deleteById(id) ); - notifications.show({ - title: 'Success', - message: 'Cost configuration deleted successfully', - color: 'green', - }); + notify.success('Cost configuration deleted successfully'); await loadCosts(); } catch (err) { console.warn('Error deleting cost:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to delete cost', - color: 'red', - }); + notify.error(err, 'Failed to delete cost'); } })(); }, @@ -260,18 +232,10 @@ export default function FunctionCostsPage() { await executeWithAdmin(client => client.functionCosts.clearCache() ); - notifications.show({ - title: 'Success', - message: 'Cache cleared successfully', - color: 'green', - }); + notify.success('Cache cleared successfully'); } catch (err) { console.warn('Error clearing cache:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to clear cache', - color: 'red', - }); + notify.error(err, 'Failed to clear cache'); } })(); }, diff --git a/WebAdmin/src/app/functions/executions/page.tsx b/WebAdmin/src/app/functions/executions/page.tsx index 692a2fc04..5252cbc4a 100644 --- a/WebAdmin/src/app/functions/executions/page.tsx +++ b/WebAdmin/src/app/functions/executions/page.tsx @@ -24,7 +24,7 @@ import { IconEye, IconTrash } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { modals } from '@mantine/modals'; import { useAdminClient } from '@/lib/client/adminClient'; import { @@ -99,11 +99,7 @@ export default function FunctionExecutionsPage() { setExecutions(response); } catch (err) { console.warn('Error loading executions:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to load executions', - color: 'red', - }); + notify.error(err, 'Failed to load executions'); } finally { setLoading(false); } @@ -148,19 +144,11 @@ export default function FunctionExecutionsPage() { const result = await executeWithAdmin(client => client.functionExecutions.cleanup(30) ); - notifications.show({ - title: 'Success', - message: `Deleted ${(result as { deletedCount?: number }).deletedCount ?? 0} executions`, - color: 'green', - }); + notify.success(`Deleted ${(result as { deletedCount?: number }).deletedCount ?? 0} executions`); await loadExecutions(); } catch (err) { console.warn('Error cleaning up executions:', err); - notifications.show({ - title: 'Error', - message: err instanceof Error ? err.message : 'Failed to cleanup executions', - color: 'red', - }); + notify.error(err, 'Failed to cleanup executions'); } })(); }, diff --git a/WebAdmin/src/app/hooks/useMediaInterface.ts b/WebAdmin/src/app/hooks/useMediaInterface.ts new file mode 100644 index 000000000..1d7c06093 --- /dev/null +++ b/WebAdmin/src/app/hooks/useMediaInterface.ts @@ -0,0 +1,83 @@ +import { useEffect, useCallback } from 'react'; +import { useDiscoveryModels, type DiscoveryModel, type DiscoveryResponse } from '@/app/chat/hooks/useDiscoveryModels'; +import { useParameterState } from '@/components/parameters/hooks/useParameterState'; +import type { ModelCapability } from '@knn_labs/conduit-gateway-client'; + +interface UseMediaInterfaceOptions { + /** The model capability to filter by (e.g., ImageGeneration, VideoGeneration) */ + capability: ModelCapability; + /** The currently selected model ID */ + currentModel: string | undefined; + /** Callback when model selection changes */ + onModelChange: (model: string) => void; + /** Callback when an error occurs */ + onError: (error: string) => void; + /** Prefix for parameter persistence key */ + parameterPersistPrefix: 'image' | 'video'; +} + +interface UseMediaInterfaceReturn { + /** Discovery response data containing available models */ + discoveryData: DiscoveryResponse | undefined; + /** Whether models are currently loading */ + modelsLoading: boolean; + /** Error from loading models, if any */ + modelsError: Error | null; + /** The currently selected model from discovery data */ + selectedDiscoveryModel: DiscoveryModel | undefined; + /** Parameter state for the selected model */ + parameterState: ReturnType; + /** Whether there's a configuration error (no models available) */ + isConfigurationError: boolean; + /** Whether models are available */ + hasModels: boolean; +} + +/** + * Shared hook for media interface pages (Image and Video generation). + * Handles model discovery, parameter state, and auto-selection. + */ +export function useMediaInterface(options: UseMediaInterfaceOptions): UseMediaInterfaceReturn { + const { capability, currentModel, onModelChange, onError, parameterPersistPrefix } = options; + + // Fetch models with capability from discovery endpoint + const { data: discoveryData, isLoading: modelsLoading, error: modelsError } = useDiscoveryModels(capability); + + // Find selected model + const selectedDiscoveryModel = discoveryData?.data?.find(m => m.id === currentModel); + + // Initialize parameter state with the model's parameters + const parameterState = useParameterState({ + parameters: selectedDiscoveryModel?.parameters ?? '{}', + persistKey: `${parameterPersistPrefix}-params-${currentModel ?? 'default'}`, + }); + + // Memoize the model change handler to prevent unnecessary effect triggers + const handleModelChange = useCallback((model: string) => { + onModelChange(model); + }, [onModelChange]); + + // Auto-select first available model + useEffect(() => { + if (discoveryData?.data && discoveryData.data.length > 0 && !currentModel) { + handleModelChange(discoveryData.data[0].id); + } + }, [discoveryData, currentModel, handleModelChange]); + + // Handle models loading error + useEffect(() => { + if (modelsError) { + onError(`Failed to load models: ${modelsError.message}`); + } + }, [modelsError, onError]); + + return { + discoveryData, + modelsLoading, + modelsError, + selectedDiscoveryModel, + parameterState, + isConfigurationError: !modelsError && (!discoveryData?.data || discoveryData.data.length === 0), + hasModels: !!discoveryData?.data && discoveryData.data.length > 0, + }; +} diff --git a/WebAdmin/src/app/images/components/ImageInterface.tsx b/WebAdmin/src/app/images/components/ImageInterface.tsx index a4f5498ef..42e9713c1 100755 --- a/WebAdmin/src/app/images/components/ImageInterface.tsx +++ b/WebAdmin/src/app/images/components/ImageInterface.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useEffect } from 'react'; import { Stack, Title, @@ -16,8 +15,7 @@ import { useImageStore } from '../hooks/useImageStore'; import { ErrorDisplay } from '@/components/common/ErrorDisplay'; import { createEnhancedError } from '@/lib/utils/error-enhancement'; import { DynamicParameters } from '@/components/parameters/DynamicParameters'; -import { useParameterState } from '@/components/parameters/hooks/useParameterState'; -import { useDiscoveryModels } from '@/app/chat/hooks/useDiscoveryModels'; +import { useMediaInterface } from '@/app/hooks/useMediaInterface'; import { ModelCapability } from '@knn_labs/conduit-gateway-client'; import ImageSettings from './ImageSettings'; import ImagePromptInput from './ImagePromptInput'; @@ -35,32 +33,21 @@ export default function ImageInterface() { setError, } = useImageStore(); - // Fetch models with image generation capability from discovery endpoint - const { data: discoveryData, isLoading: modelsLoading, error: modelsError } = useDiscoveryModels(ModelCapability.ImageGeneration); - - // Find the selected model with parameters - const selectedDiscoveryModel = discoveryData?.data?.find(m => m.id === settings.model); - - // Initialize parameter state with the model's parameters - const parameterState = useParameterState({ - parameters: selectedDiscoveryModel?.parameters ?? '{}', - persistKey: `image-params-${settings.model ?? 'default'}`, + // Use shared media interface hook for model discovery and parameter management + const { + discoveryData, + modelsLoading, + modelsError, + selectedDiscoveryModel, + parameterState, + } = useMediaInterface({ + capability: ModelCapability.ImageGeneration, + currentModel: settings.model, + onModelChange: (model) => updateSettings({ model }), + onError: setError, + parameterPersistPrefix: 'image', }); - // Auto-select first available model - useEffect(() => { - if (discoveryData?.data && discoveryData.data.length > 0 && !settings.model) { - updateSettings({ model: discoveryData.data[0].id }); - } - }, [discoveryData, settings.model, updateSettings]); - - // Handle models loading error - useEffect(() => { - if (modelsError) { - setError(`Failed to load models: ${modelsError.message}`); - } - }, [modelsError, setError]); - if (modelsLoading) { return ( diff --git a/WebAdmin/src/app/images/hooks/useImageStore.ts b/WebAdmin/src/app/images/hooks/useImageStore.ts index 09092e248..10d045296 100755 --- a/WebAdmin/src/app/images/hooks/useImageStore.ts +++ b/WebAdmin/src/app/images/hooks/useImageStore.ts @@ -12,6 +12,7 @@ import { createToastErrorHandler, shouldShowBalanceWarning } from '@knn_labs/conduit-gateway-client'; +// Needs raw notifications API: .show is passed as callback to SDK's createToastErrorHandler import { notifications } from '@mantine/notifications'; const LOCAL_STORAGE_KEY = 'conduit-image-generation'; diff --git a/WebAdmin/src/app/images/types/index.ts b/WebAdmin/src/app/images/types/index.ts index 64d22e3a5..80185e59a 100755 --- a/WebAdmin/src/app/images/types/index.ts +++ b/WebAdmin/src/app/images/types/index.ts @@ -1,10 +1,11 @@ // Local type definitions to avoid broken SDK imports -import { +import { MediaData, Quality, Style, - MediaGenerationStatus + MediaGenerationStatus, + RetryHistoryEntry } from '@/app/types/media'; // Re-export for components that use ErrorResponse @@ -65,11 +66,7 @@ export interface ImageTask { error?: string; settings: ImageGenerationSettings; retryCount: number; - retryHistory: Array<{ - attemptNumber: number; - timestamp: string; - error: string; - }>; + retryHistory: RetryHistoryEntry[]; } diff --git a/WebAdmin/src/app/ip-filtering/handlers.ts b/WebAdmin/src/app/ip-filtering/handlers.ts index b020436cb..319d5ef01 100644 --- a/WebAdmin/src/app/ip-filtering/handlers.ts +++ b/WebAdmin/src/app/ip-filtering/handlers.ts @@ -1,6 +1,7 @@ import { useSecurityApi, type IpRule } from '@/hooks/useSecurityApi'; import { withAdminClient } from '@/lib/client/adminClient'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; +import type { IpFilterTemplate, IpTemplateRule } from '@/components/ip-filtering/ipFilterTemplates'; export function useIpFilteringHandlers( fetchIpRules: () => Promise, @@ -41,21 +42,12 @@ export function useIpFilteringHandlers( await Promise.all(promises); - notifications.show({ - title: 'Success', - message: `Successfully ${operation}d ${selectedRules.length} rule(s)`, - color: 'green', - }); + notify.success(`Successfully ${operation}d ${selectedRules.length} rule(s)`); await fetchIpRules(); setSelectedRules([]); } catch (error) { - const message = error instanceof Error ? error.message : `Failed to ${operation} rules`; - notifications.show({ - title: 'Error', - message, - color: 'red', - }); + notify.error(error, `Failed to ${operation} rules`); } }; @@ -101,18 +93,9 @@ export function useIpFilteringHandlers( window.URL.revokeObjectURL(url); document.body.removeChild(a); - notifications.show({ - title: 'Success', - message: `IP rules exported as ${format.toUpperCase()}`, - color: 'green', - }); + notify.success(`IP rules exported as ${format.toUpperCase()}`); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to export IP rules'; - notifications.show({ - title: 'Error', - message, - color: 'red', - }); + notify.error(error, 'Failed to export IP rules'); } }; @@ -190,20 +173,11 @@ export function useIpFilteringHandlers( } } - notifications.show({ - title: 'Success', - message: `Imported ${imported} rule(s) successfully${failed > 0 ? `, ${failed} failed` : ''}`, - color: 'green', - }); + notify.success(`Imported ${imported} rule(s) successfully${failed > 0 ? `, ${failed} failed` : ''}`); await fetchIpRules(); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to import IP rules'; - notifications.show({ - title: 'Error', - message, - color: 'red', - }); + notify.error(error, 'Failed to import IP rules'); } }; @@ -256,6 +230,49 @@ export function useIpFilteringHandlers( } }; + const handleApplyTemplate = async ( + template: IpFilterTemplate, + rulesToCreate: IpTemplateRule[], + setIsSubmitting: React.Dispatch> + ) => { + if (rulesToCreate.length === 0) return; + + setIsSubmitting(true); + let created = 0; + let failed = 0; + + try { + for (const rule of rulesToCreate) { + try { + await withAdminClient(client => + client.ipFilters.create({ + name: rule.name, + ipAddressOrCidr: rule.ipAddressOrCidr, + filterType: 'whitelist', + isEnabled: true, + description: rule.description, + }) + ); + created++; + } catch { + failed++; + } + } + + if (failed > 0) { + notify.warning(`Created ${created} rule${created !== 1 ? 's' : ''} from "${template.label}", ${failed} failed`); + } else { + notify.success(`Created ${created} rule${created !== 1 ? 's' : ''} from "${template.label}"`, 'Template Applied'); + } + + await fetchIpRules(); + } catch (error) { + notify.error(error, 'Failed to apply template'); + } finally { + setIsSubmitting(false); + } + }; + return { handleBulkOperation, handleExport, @@ -263,5 +280,6 @@ export function useIpFilteringHandlers( handleDeleteRule, handleToggleRule, handleModalSubmit, + handleApplyTemplate, }; } \ No newline at end of file diff --git a/WebAdmin/src/app/ip-filtering/hooks.ts b/WebAdmin/src/app/ip-filtering/hooks.ts index 3e1bc6b40..66bf205cd 100644 --- a/WebAdmin/src/app/ip-filtering/hooks.ts +++ b/WebAdmin/src/app/ip-filtering/hooks.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { useSecurityApi, type IpRule, type IpStats } from '@/hooks/useSecurityApi'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; export function useIpFilteringData() { const [isLoading, setIsLoading] = useState(true); @@ -27,11 +27,7 @@ export function useIpFilteringData() { }; setStats(calculatedStats); } catch { - notifications.show({ - title: 'Error', - message: 'Failed to load IP rules', - color: 'red', - }); + notify.error('Failed to load IP rules'); } finally { setIsLoading(false); } diff --git a/WebAdmin/src/app/ip-filtering/page.tsx b/WebAdmin/src/app/ip-filtering/page.tsx index 60e49d5a3..a0459a814 100755 --- a/WebAdmin/src/app/ip-filtering/page.tsx +++ b/WebAdmin/src/app/ip-filtering/page.tsx @@ -23,6 +23,8 @@ import { IconFileTypeCsv, IconJson, IconTestPipe, + IconChevronDown, + IconTemplate, } from '@tabler/icons-react'; import { useState, useEffect } from 'react'; import { useDisclosure } from '@mantine/hooks'; @@ -30,6 +32,9 @@ import { type IpRule } from '@/hooks/useSecurityApi'; import { IpRulesTable } from '@/components/ip-filtering/IpRulesTable'; import { IpRuleModal } from '@/components/ip-filtering/IpRuleModal'; import { IpTestModal } from '@/components/ip-filtering/IpTestModal'; +import { IpTemplateModal } from '@/components/ip-filtering/IpTemplateModal'; +import { IpFilterPolicyBanner } from '@/components/ip-filtering/IpFilterPolicyBanner'; +import { ipFilterTemplates, type IpFilterTemplate } from '@/components/ip-filtering/ipFilterTemplates'; import { useIpFilteringData } from './hooks'; import { useIpFilteringHandlers } from './handlers'; import { IpFilteringStats } from './IpFilteringStats'; @@ -40,6 +45,8 @@ export default function IpFilteringPage() { const [selectedRule, setSelectedRule] = useState(null); const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const [testModalOpened, { open: openTestModal, close: closeTestModal }] = useDisclosure(false); + const [templateModalOpened, { open: openTemplateModal, close: closeTemplateModal }] = useDisclosure(false); + const [selectedTemplate, setSelectedTemplate] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const { isLoading, rules, stats, fetchIpRules } = useIpFilteringData(); @@ -50,6 +57,7 @@ export default function IpFilteringPage() { handleDeleteRule, handleToggleRule, handleModalSubmit, + handleApplyTemplate, } = useIpFilteringHandlers(fetchIpRules, setSelectedRules); useEffect(() => { @@ -137,15 +145,43 @@ export default function IpFilteringPage() { Import - + + + + + + + + Apply Template + {ipFilterTemplates.map(template => ( + } + onClick={() => { + setSelectedTemplate(template); + openTemplateModal(); + }} + > + {template.label} + + ))} + + +
@@ -233,7 +269,7 @@ export default function IpFilteringPage() { {/* IP Rules Table */} - void handleToggleRule(ruleId, enabled, rules)} /> + + {/* Effective Policy Banner */} + {!isLoading && ( + + + + )}
@@ -264,6 +307,24 @@ export default function IpFilteringPage() { opened={testModalOpened} onClose={closeTestModal} /> + + {/* IP Template Modal */} + { + closeTemplateModal(); + setSelectedTemplate(null); + }} + template={selectedTemplate} + existingRules={rules} + onConfirm={(template, rulesToCreate) => { + void handleApplyTemplate(template, rulesToCreate, setIsSubmitting).then(() => { + closeTemplateModal(); + setSelectedTemplate(null); + }); + }} + isLoading={isSubmitting} + /> ); } \ No newline at end of file diff --git a/WebAdmin/src/app/llm-providers/[id]/keys/page.tsx b/WebAdmin/src/app/llm-providers/[id]/keys/page.tsx index e32fa8c7b..30619110c 100755 --- a/WebAdmin/src/app/llm-providers/[id]/keys/page.tsx +++ b/WebAdmin/src/app/llm-providers/[id]/keys/page.tsx @@ -33,7 +33,7 @@ import { IconTestPipe, IconArrowLeft, } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { modals } from '@mantine/modals'; import type { ProviderDto, ProviderKeyCredentialDto, CreateProviderKeyCredentialDto } from '@knn_labs/conduit-admin-client'; import { withAdminClient } from '@/lib/client/adminClient'; @@ -70,11 +70,7 @@ export default function ProviderKeysPage() { setProvider(data); } catch (error) { console.error('Error fetching provider:', error); - notifications.show({ - title: 'Error', - message: 'Failed to load provider details', - color: 'red', - }); + notify.error(new Error('Failed to load provider details')); } }, [providerId]); @@ -87,11 +83,7 @@ export default function ProviderKeysPage() { setKeys(data); } catch (error) { console.error('Error fetching provider keys:', error); - notifications.show({ - title: 'Error', - message: 'Failed to load provider keys', - color: 'red', - }); + notify.error(new Error('Failed to load provider keys')); } finally { setIsLoading(false); } @@ -111,11 +103,7 @@ export default function ProviderKeysPage() { client.providers.createKey(providerId, newKeyForm) ); - notifications.show({ - title: 'Success', - message: 'Provider key added successfully', - color: 'green', - }); + notify.success('Provider key added successfully'); // Reset form setNewKeyForm({ @@ -133,12 +121,7 @@ export default function ProviderKeysPage() { void fetchKeys(); } catch (error) { console.error('Error adding key:', error); - const errorMessage = error instanceof Error ? error.message : 'Failed to add provider key'; - notifications.show({ - title: 'Error', - message: errorMessage, - color: 'red', - }); + notify.error(error, 'Failed to add provider key'); } finally { setIsAddingKey(false); } @@ -150,20 +133,12 @@ export default function ProviderKeysPage() { client.providers.setPrimaryKey(providerId, keyId) ); - notifications.show({ - title: 'Success', - message: 'Primary key updated', - color: 'green', - }); - + notify.success('Primary key updated'); + void fetchKeys(); } catch (error) { console.error('Error setting primary key:', error); - notifications.show({ - title: 'Error', - message: 'Failed to set primary key', - color: 'red', - }); + notify.error(new Error('Failed to set primary key')); } }; @@ -173,20 +148,12 @@ export default function ProviderKeysPage() { client.providers.updateKey(providerId, keyId, { isEnabled: enabled }) ); - notifications.show({ - title: 'Success', - message: `Key ${enabled ? 'enabled' : 'disabled'} successfully`, - color: 'green', - }); - + notify.success(`Key ${enabled ? 'enabled' : 'disabled'} successfully`); + void fetchKeys(); } catch (error) { console.error('Error updating key:', error); - notifications.show({ - title: 'Error', - message: 'Failed to update key', - color: 'red', - }); + notify.error(new Error('Failed to update key')); } }; @@ -200,28 +167,17 @@ export default function ProviderKeysPage() { // Handle new response format const isSuccess = (result.result as string) === 'success'; const testResult = result.result as string; - - const colors: Record = { - 'success': 'green', - 'invalid_key': 'red', - 'ignored': 'yellow', - 'provider_down': 'orange', - 'rate_limited': 'orange', - 'unknown_error': 'red' - }; - - notifications.show({ - title: isSuccess ? 'Key Test Successful' : 'Key Test Failed', - message: result.message ?? (isSuccess ? 'The API key is valid and working' : 'The API key is invalid or not working'), - color: colors[testResult] ?? 'red', - }); + + if (isSuccess) { + notify.success(result.message ?? 'The API key is valid and working', 'Key Test Successful'); + } else if (testResult === 'ignored' || testResult === 'provider_down' || testResult === 'rate_limited') { + notify.warning(result.message ?? 'The API key is invalid or not working', 'Key Test Failed'); + } else { + notify.error(new Error(result.message ?? 'The API key is invalid or not working'), 'Key Test Failed'); + } } catch (error) { console.error('Error testing key:', error); - notifications.show({ - title: 'Error', - message: 'Failed to test key', - color: 'red', - }); + notify.error(new Error('Failed to test key')); } finally { setTestingKeys(prev => { const newSet = new Set(prev); @@ -233,11 +189,7 @@ export default function ProviderKeysPage() { const handleDeleteKey = (key: ProviderKeyCredentialDto) => { if (key.isPrimary) { - notifications.show({ - title: 'Cannot delete primary key', - message: 'Please set another key as primary before deleting this one', - color: 'red', - }); + notify.error(new Error('Please set another key as primary before deleting this one'), 'Cannot delete primary key'); return; } @@ -257,20 +209,12 @@ export default function ProviderKeysPage() { client.providers.deleteKey(providerId, key.id) ); - notifications.show({ - title: 'Success', - message: 'Key deleted successfully', - color: 'green', - }); - + notify.success('Key deleted successfully'); + void fetchKeys(); } catch (error) { console.error('Error deleting key:', error); - notifications.show({ - title: 'Error', - message: 'Failed to delete key', - color: 'red', - }); + notify.error(new Error('Failed to delete key')); } })(); }, diff --git a/WebAdmin/src/app/llm-providers/page.tsx b/WebAdmin/src/app/llm-providers/page.tsx index e6572a0a6..a0ad5d948 100755 --- a/WebAdmin/src/app/llm-providers/page.tsx +++ b/WebAdmin/src/app/llm-providers/page.tsx @@ -29,7 +29,7 @@ import { } from '@tabler/icons-react'; import { useState, useEffect } from 'react'; import { ProvidersTable } from '@/components/providers/ProvidersTable'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { useRouter } from 'next/navigation'; import { exportToCSV, exportToJSON, formatDateForExport } from '@/lib/utils/export'; import { TablePagination } from '@/components/common/TablePagination'; @@ -110,20 +110,16 @@ export default function ProvidersPage() { client.providers.testConnectionById(providerId) ); - notifications.show({ - title: result.result === ApiKeyTestResult.SUCCESS ? 'Connection Successful' : 'Connection Failed', - message: result.message ?? (result.result === ApiKeyTestResult.SUCCESS ? 'Provider is working correctly' : 'Failed to connect to provider'), - color: result.result === ApiKeyTestResult.SUCCESS ? 'green' : 'red', - }); + if (result.result === ApiKeyTestResult.SUCCESS) { + notify.success(result.message ?? 'Provider is working correctly', 'Connection Successful'); + } else { + notify.error(new Error(result.message ?? 'Failed to connect to provider'), 'Connection Failed'); + } // Refresh providers to get updated health status void fetchProviders(); } catch { - notifications.show({ - title: 'Error', - message: 'Failed to test provider connection', - color: 'red', - }); + notify.error(new Error('Failed to test provider connection')); } finally { setTestingProviders(prev => { const newSet = new Set(prev); @@ -138,18 +134,10 @@ export default function ProvidersPage() { await withAdminClient(client => client.providers.deleteById(providerId) ); - notifications.show({ - title: 'Success', - message: 'Provider deleted successfully', - color: 'green', - }); + notify.success('Provider deleted successfully'); void fetchProviders(); } catch { - notifications.show({ - title: 'Error', - message: 'Failed to delete provider', - color: 'red', - }); + notify.error(new Error('Failed to delete provider')); } }; @@ -192,17 +180,13 @@ export default function ProvidersPage() { const handleExportCSV = () => { if (filteredProviders.length === 0) { - notifications.show({ - title: 'No data to export', - message: 'There are no providers to export', - color: 'orange', - }); + notify.warning('There are no providers to export', 'No data to export'); return; } const exportData = filteredProviders.map((provider) => { const displayName = provider.providerType ? getProviderDisplayName(provider.providerType) : 'Unknown Provider'; - + return { name: provider.providerName ?? displayName, type: displayName, @@ -230,20 +214,12 @@ export default function ProvidersPage() { ] ); - notifications.show({ - title: 'Export successful', - message: `Exported ${filteredProviders.length} providers`, - color: 'green', - }); + notify.success(`Exported ${filteredProviders.length} providers`, 'Export successful'); }; const handleExportJSON = () => { if (filteredProviders.length === 0) { - notifications.show({ - title: 'No data to export', - message: 'There are no providers to export', - color: 'orange', - }); + notify.warning('There are no providers to export', 'No data to export'); return; } @@ -252,11 +228,7 @@ export default function ProvidersPage() { `providers-${new Date().toISOString().split('T')[0]}` ); - notifications.show({ - title: 'Export successful', - message: `Exported ${filteredProviders.length} providers`, - color: 'green', - }); + notify.success(`Exported ${filteredProviders.length} providers`, 'Export successful'); }; const statCards = [ diff --git a/WebAdmin/src/app/media-assets/cleanup-status/MediaCleanupStatusContent.tsx b/WebAdmin/src/app/media-assets/cleanup-status/MediaCleanupStatusContent.tsx index de43fdfc4..bf8b4b149 100644 --- a/WebAdmin/src/app/media-assets/cleanup-status/MediaCleanupStatusContent.tsx +++ b/WebAdmin/src/app/media-assets/cleanup-status/MediaCleanupStatusContent.tsx @@ -30,7 +30,7 @@ import { IconExternalLink, } from '@tabler/icons-react'; import Link from 'next/link'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { withAdminClient } from '@/lib/client/adminClient'; import type { MediaCleanupStatus } from '@knn_labs/conduit-admin-client'; @@ -106,19 +106,10 @@ export default function MediaCleanupStatusContent() { const response = await withAdminClient(client => client.media.setCleanupServiceEnabled(enabled) ); - notifications.show({ - title: 'Success', - message: response.message ?? `Cleanup service ${enabled ? 'enabled' : 'disabled'}`, - color: 'green', - }); + notify.success(response.message ?? `Cleanup service ${enabled ? 'enabled' : 'disabled'}`); void fetchStatus(); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to toggle service'; - notifications.show({ - title: 'Error', - message, - color: 'red', - }); + notify.error(err, 'Failed to toggle service'); } finally { setToggleLoading(false); } @@ -130,20 +121,11 @@ export default function MediaCleanupStatusContent() { const response = await withAdminClient(client => client.media.setSimpleRetentionOverride(simpleRetentionDays) ); - notifications.show({ - title: 'Success', - message: response.message ?? 'Simple retention override updated', - color: 'green', - }); + notify.success(response.message ?? 'Simple retention override updated'); setHasUnsavedChanges(false); void fetchStatus(); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to update retention'; - notifications.show({ - title: 'Error', - message, - color: 'red', - }); + notify.error(err, 'Failed to update retention'); } finally { setRetentionLoading(false); } diff --git a/WebAdmin/src/app/media-assets/components/CleanupModal.tsx b/WebAdmin/src/app/media-assets/components/CleanupModal.tsx index f758ea065..25c0561a6 100644 --- a/WebAdmin/src/app/media-assets/components/CleanupModal.tsx +++ b/WebAdmin/src/app/media-assets/components/CleanupModal.tsx @@ -1,9 +1,24 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Modal, Stack, Text, Button, NumberInput, Alert, Group } from '@mantine/core'; import { IconTrash, IconAlertCircle } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; +import { useConfirmModal } from '@/hooks/useFormModal'; + +async function runCleanup(type: 'expired' | 'orphaned' | 'prune', daysToKeep?: number): Promise { + const response = await fetch('/api/media/cleanup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type, + ...(type === 'prune' && { daysToKeep }) + }), + }); + + if (!response.ok) { + throw new Error('Cleanup failed'); + } +} interface CleanupModalProps { opened: boolean; @@ -12,45 +27,33 @@ interface CleanupModalProps { } export default function CleanupModal({ opened, onClose, onSuccess }: CleanupModalProps) { - const [loading, setLoading] = useState(false); const [daysToKeep, setDaysToKeep] = useState(90); - const handleCleanup = async (type: 'expired' | 'orphaned' | 'prune') => { - setLoading(true); - try { - const response = await fetch('/api/media/cleanup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type, - ...(type === 'prune' && { daysToKeep }) - }), - }); + const { loading: expiredLoading, handleConfirm: handleExpired } = useConfirmModal({ + onClose, + onSuccess, + confirmAction: () => runCleanup('expired'), + successMessage: 'Expired media cleaned up successfully', + }); - if (!response.ok) { - throw new Error('Cleanup failed'); - } + const { loading: orphanedLoading, handleConfirm: handleOrphaned } = useConfirmModal({ + onClose, + onSuccess, + confirmAction: () => runCleanup('orphaned'), + successMessage: 'Orphaned media cleaned up successfully', + }); - const data = await response.json() as { message: string }; - - notifications.show({ - title: 'Cleanup Successful', - message: data.message, - color: 'green', - }); + const { loading: pruneLoading, handleConfirm: handlePrune } = useConfirmModal({ + onClose, + onSuccess, + confirmAction: () => runCleanup('prune', daysToKeep), + successMessage: `Media older than ${daysToKeep} days pruned successfully`, + }); - onSuccess(); - onClose(); - } catch { - notifications.show({ - title: 'Cleanup Failed', - message: 'An error occurred during cleanup', - color: 'red', - }); - } finally { - setLoading(false); - } - }; + const loading = useMemo( + () => expiredLoading || orphanedLoading || pruneLoading, + [expiredLoading, orphanedLoading, pruneLoading] + ); return ( } - onClick={() => void handleCleanup('expired')} - loading={loading} + onClick={() => void handleExpired()} + loading={expiredLoading} + disabled={loading && !expiredLoading} fullWidth > Clean Expired Media @@ -93,8 +97,9 @@ export default function CleanupModal({ opened, onClose, onSuccess }: CleanupModa variant="light" color="orange" leftSection={} - onClick={() => void handleCleanup('orphaned')} - loading={loading} + onClick={() => void handleOrphaned()} + loading={orphanedLoading} + disabled={loading && !orphanedLoading} fullWidth > Clean Orphaned Media @@ -118,8 +123,9 @@ export default function CleanupModal({ opened, onClose, onSuccess }: CleanupModa variant="light" color="red" leftSection={} - onClick={() => void handleCleanup('prune')} - loading={loading} + onClick={() => void handlePrune()} + loading={pruneLoading} + disabled={loading && !pruneLoading} fullWidth > Prune Old Media @@ -135,4 +141,4 @@ export default function CleanupModal({ opened, onClose, onSuccess }: CleanupModa ); -} \ No newline at end of file +} diff --git a/WebAdmin/src/app/media-assets/components/MediaAssetsContent.tsx b/WebAdmin/src/app/media-assets/components/MediaAssetsContent.tsx index ff5730086..b1af68fde 100644 --- a/WebAdmin/src/app/media-assets/components/MediaAssetsContent.tsx +++ b/WebAdmin/src/app/media-assets/components/MediaAssetsContent.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { Stack, Group, Button, Select, Text } from '@mantine/core'; import { IconRefresh, IconTrash } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; +import { notify } from '@/lib/notifications'; import { modals } from '@mantine/modals'; import { withAdminClient } from '@/lib/client/adminClient'; import { useMediaAssets } from '../hooks/useMediaAssets'; @@ -48,21 +48,17 @@ export default function MediaAssetsContent() { const fetchKeyGroups = async () => { try { setLoadingKeyGroups(true); - const result = await withAdminClient(client => + const result = await withAdminClient(client => client.virtualKeyGroups.list() ); - const groups = result.map((group) => ({ + const groups = result.items.map((group) => ({ id: group.id, name: group.groupName })); setKeyGroups(groups); } catch (error) { console.error('Failed to fetch key groups:', error); - notifications.show({ - title: 'Error', - message: 'Failed to load key groups', - color: 'red', - }); + notify.error('Failed to load key groups'); } finally { setLoadingKeyGroups(false); } @@ -103,11 +99,7 @@ export default function MediaAssetsContent() { } } catch (error) { console.error('Failed to fetch virtual keys:', error); - notifications.show({ - title: 'Error', - message: 'Failed to load virtual keys', - color: 'red', - }); + notify.error('Failed to load virtual keys'); } finally { setLoadingVirtualKeys(false); } @@ -146,17 +138,27 @@ export default function MediaAssetsContent() { onConfirm: () => { void (async () => { const selectedMedia = getSelectedMedia(); - + let successCount = 0; + let failCount = 0; + for (const media of selectedMedia) { - await deleteMedia(media.id); + const success = await deleteMedia(media.id, false); + if (success) { + successCount++; + } else { + failCount++; + } } - + deselectAll(); - notifications.show({ - title: 'Success', - message: `Deleted ${count} media items`, - color: 'green', - }); + + if (failCount === 0) { + notify.success(`Deleted ${successCount} media items`); + } else if (successCount === 0) { + notify.error(`Failed to delete ${failCount} media items`); + } else { + notify.warning(`Deleted ${successCount} of ${count} items. ${failCount} failed.`, 'Partial Success'); + } })(); }, }); @@ -179,11 +181,7 @@ export default function MediaAssetsContent() { } } - notifications.show({ - title: 'Success', - message: `Downloaded ${selectedCount} files`, - color: 'green', - }); + notify.success(`Downloaded ${selectedCount} files`); }; // Get unique providers from media diff --git a/WebAdmin/src/app/media-assets/components/MediaCard.tsx b/WebAdmin/src/app/media-assets/components/MediaCard.tsx index e0d83b636..cf0a68617 100644 --- a/WebAdmin/src/app/media-assets/components/MediaCard.tsx +++ b/WebAdmin/src/app/media-assets/components/MediaCard.tsx @@ -3,7 +3,8 @@ import { Card, Image, Text, Group, Badge, Checkbox, ActionIcon, Stack } from '@mantine/core'; import { IconDownload, IconEye, IconTrash } from '@tabler/icons-react'; import { MediaRecord } from '../types'; -import { formatBytes, formatDate, getProviderColor } from '../utils/formatters'; +import { getProviderColor } from '../utils/formatters'; +import { formatters } from '@/lib/utils/formatters'; interface MediaCardProps { media: MediaRecord; @@ -20,18 +21,14 @@ export default function MediaCard({ onView, onDelete }: MediaCardProps) { - const getThumbnail = () => { - if (media.mediaType === 'image' && media.publicUrl) { - return media.publicUrl; - } - // For videos, we'd need a thumbnail service or use a placeholder - return '/api/placeholder/400/300'; - }; + const mediaUrl = media.publicUrl ?? media.storageUrl; + const isVideo = media.mediaType.toLowerCase() === 'video'; + const isImage = media.mediaType.toLowerCase() === 'image'; const handleDownload = () => { - if (media.publicUrl) { + if (mediaUrl) { const link = document.createElement('a'); - link.href = media.publicUrl; + link.href = mediaUrl; link.download = `${media.mediaType}-${media.id}`; document.body.appendChild(link); link.click(); @@ -53,9 +50,9 @@ export default function MediaCard({ style={{ cursor: 'pointer', position: 'relative', paddingTop: '75%' }} onClick={() => onView(media)} > - {media.mediaType === 'image' ? ( + {isImage ? ( {media.prompt + ) : mediaUrl ? ( +